ユーニックス総合研究所

  • home
  • archives
  • spacy-skytree-to-umi

spaCyでスカイツリーと海、どっちが高いか判定する【自然言語処理, Python】

spaCyでスカイツリーと海の意味をとらえる

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

Pythonには自然言語処理を行えるライブラリとしてspaCyがあります。
今回はこのspaCyを使ってスカイツリーと海はどっちが高いのか判定してみたいと思います。

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

  • spaCyとは?
  • スカイツリーと海の高さとは?
  • スクリプトの実行結果
  • スクリプトの設計
  • スクリプトのソースコード
  • ソースコードの解説

spaCyとは?

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

そしてGiNZA(ギンザ)はリクルートと国立国語研究所が共同開発した日本語に特化した自然言語処理ライブラリです。
こちらもオープンソース、MITライセンスで開発されています。
このGiNZAが提供するモデルをspaCyでロードすることで、spaCyからGiNZAの機能を使うことができます。

spaCyによる日本語の解析では公式サポートのspacy.lang.jaを使うか、こちらのGiNZAを使うという選択肢があります。

スカイツリーと海の高さとは?

「高いスカイツリーと海が美しい」という文があります。
これの「高い」は「スカイツリー」と「海」のどちらに係(かか)っているのでしょうか?
我々は日本語を意識せずに使うことができるので、「高い」は「スカイツリー」に係っていると瞬間的に理解できます。

しかし自然言語処理では、↑のような人間が行う解析は機械には難しいとされています。
これは「スカイツリーは高いけど、海に高さは無い」という常識が我々に備わっているのに対して、解析する機械にはこのような常識が備わっていないためです。
そのため機械は「高いスカイツリーと海」という文の「高い」は「スカイツリー」と「海」の両方に係っていると判断してしまいます。

このような文の意味を理解して、適切な単語の係り方を選択することを自然言語処理では「意味解析」と言います。

今回の内容は意味解析なのか?

自然言語処理には複数の工程がありますが、意味解析はその工程の後半の解析です。

  1. 字句解析
  2. 構文解析
  3. 意味解析 <- これ
  4. 文脈解析

意味解析はまだ世界中で研究中の解析のため、実用性のあるプログラムを作ることが困難とされています。
今回の記事では意味解析「っぽい」ことはやっていますが、間違ってる所があったらツッコミお願いします。

🦝 < 意味解析のサンプルが少ないんだよね

🐭 < 我にもっと意味解析を

スクリプトの実行結果

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

.  
----------------------------------------------------------------------  
Ran 1 test in 0.848s  

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('高い海と大地が美しい', None)  # 該当する単語なし  
        self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー')  

        # 海は広いがスカイツリーが広いは変  
        # その辺りの解釈の解決  
        self.eq('広いスカイツリーと海が美しい', '広い = 海')  
        self.eq('広い海とスカイツリーが美しい', '広い = 海')  
        self.eq('広いスカイツリーとお猪口が美しい', None)  # 該当する単語なし  

        # スカイツリーと海は両方とも美しい  
        self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海')  
        self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー')  

        # 微妙なケース  
        # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、  
        # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう  
        # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、  
        # 優先順位を付けていないので↓のような結果になる  
        # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない  
        self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')  

↑のテストケースを見ると、「高いスカイツリーと海が美しい」という文では「高い = スカイツリー」と出力されています。
これは解析器が「高いのはスカイツリーね」と理解している結果と言えそうです。

ただ「高い海とスカイツリーが美しい」では、これは文章的には「高い」は「海」に係っているのですが、今回作成する解析器はこれを「高い = スカイツリー」と出力します。つまり「高い海」というのを無視しているわけです。「高い海」というのは意味的にはおかしい表現ですが、文章の表現的には確定した表現です。つまり発話者は意図的に「高い海」と発話してるわけです。しかし解析器はこれを理解していません。この辺は疑問符が付く結果と言えます。

「広いスカイツリーと海が美しい」では「広い = 海」と出力され、解析器は「広い」のは「海」と理解しています。これも同上の疑問符が付きます。

「美しいスカイツリーと海」では「美しい = スカイツリー,海」と出力され、解析器は「美しい」は「スカイツリー」と「海」の両方に係ると判断しています。

スクリプトの設計

スクリプトの動作検証は単体テストで行います。
単体テストはPythonの標準ライブラリである「unittest」を使います。

解析のメインの処理はAnalyzerというクラスに実装します。
Analyzerにはanalyze()というメソッドがあり、これが解析のエントリーポイントとなるメソッドです。

analyze()は内部で引数をspaCyで解析し、トークン列にします。
これを文ごとに取り出して1文ずつanalyze_sent()で解析します。
analyze_sent()ではトークン列を走査し、名詞か固有名詞のトークンを見つけたらanalyze_key_token()でそのトークンを解析します。

analyze_key_token()内ではクラスの意味データーベースを参照してトークンを解析します。
意味データベースと言うのは、ただの辞書です。
意味と言うのは単語の持つ「高さ」「広さ」「美しさ」です。
これらの意味を考慮して、形容詞と意味がマッチしているか判断します。
たとえば「高いスカイツリー」であれば「高い」は「高さ」になり、「スカイツリー」の「高さ」の意味は「高い」になります。そのため「高いスカイツリー」という組み合わせはマッチします。
いっぽう、「高い海」というのは「高い」は「高さ」で、「海」の「高さ」の意味は定義されていません。そのためこの組み合わせは成立しません。

意味データベースはクラスの属性imi_dbに定義されています。これはただの辞書ですが、単語の意味が保存されています。

    # 意味データベース  
    # 単語の意味が保存されている  
    self.imi_db = {  
        'スカイツリー': {  # スカイツリーと言う単語は  
            '高さ': '高い',  # 高いし  
            '広さ': None,  # 広さは無いけど  
            '美しさ': '美しい',  # 美しい  
        },  
        '海': {  # 海と言う単語は  
            '高さ': None,  # 高さは無いけど  
            '広さ': '広大',  # 広大で  
            '美しさ': '美しい',  # 美しい  
        },  
    }  

また形容詞を意味のキーに変換するにはrelated_imi_keysという辞書を使います。
これは「高い」という形容詞から「高さ」というキーを導くためのマップです。

    # 関連する意味キー  
    # 「高い」という単語から「高さ」という意味を連想させる  
    self.related_imi_keys = {  
        '高い': '高さ',  
        '広い': '広さ',  
        '美しい': '美しさ',  
    }  

意味データベースを参照して形容詞が名詞または固有名詞に適切な表現だったら、その結果を辞書に保存します。
そして最終的にその辞書を整形して、「高い = スカイツリー」などのような出力にします。

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

今回作成したスクリプトのソースコードは↓になります。

"""  
スカイツリーと海がどっちが高いか解析するスクリプト  

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


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


class Analyzer:  
    def __init__(self):  
        # 意味データベース  
        # 単語の意味が保存されている  
        self.imi_db = {  
            'スカイツリー': {  # スカイツリーと言う単語は  
                '高さ': '高い',  # 高いし  
                '広さ': None,  # 広さは無いけど  
                '美しさ': '美しい',  # 美しい  
            },  
            '海': {  # 海と言う単語は  
                '高さ': None,  # 高さは無いけど  
                '広さ': '広大',  # 広大で  
                '美しさ': '美しい',  # 美しい  
            },  
        }  

        # 関連する意味キー  
        # 「高い」という単語から「高さ」という意味を連想させる  
        self.related_imi_keys = {  
            '高い': '高さ',  
            '広い': '広さ',  
            '美しい': '美しさ',  
        }  

    def analyze(self, text):  
        """  
        引数textを解析して返答を生成する  
        """  
        doc = nlp(text)  # spaCyで解析する  

        for sent in doc.sents:  # 文を取り出す  
            result = self.analyze_sent(sent)  # 文ごとに解析する  
            if result:  # 解析に成功した  
                return self.format_result(result)  # 結果を文字列に整形して返す  

        return None  # 解析に失敗した  

    def format_result(self, d):  
        """  
        引数dを文字列に整形する  
        dは { 高い: [スカイツリー, エッフェル塔] } のようなフォーマットで保存されている  
        これを文字列「高い = スカイツリー,エッフェル塔」のようにする  
        """  
        s = ''  
        for key in d.keys():  
            s += key + ' = ' + ','.join(d[key]) + '\n'  
        s = s.rstrip()  
        return s    

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        d = {}  
        for tok in sent:  
            # 名詞(NOUN)か固有名詞(PROPN)を見つけたら  
            if tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN':  
                self.analyze_key_token(d, tok)  # 解析開始  

        return d  

    def analyze_key_token(self, d, tok):  
        """  
        引数tokを解析して結果を引数dに保存する  
        """  
        # ADJ(形容詞)を子要素から探す  
        adj = self.find_children(tok, 'ADJ')  
        if not adj:  
            return  # ADJが見つからない  

        # 形容詞をキーにしてrelated_imi_keysからキーを探す  
        imi_key = self.find_imi_key(adj.text)  
        if not imi_key:  
            return  # 見つからない  

        # 単語をキーにして意味を探す  
        imi = self.find_imi(tok.text)  
        if not imi:  
            return  # 見つからない  

        # 意味キーで意味を参照する  
        value = imi[imi_key]  
        if not value:  
            return  # 意味が見つからない  

        # 形容詞をキーにして辞書に単語を保存  
        if adj.text not in d.keys():  
            d[adj.text] = [tok.text]  
        else:  
            d[adj.text].append(tok.text)  

    def find_children(self, tok, pos_):  
        """  
        tokの子要素から再帰的にpos_にヒットする単語を探す  
        """  
        if tok.pos_ == pos_:  
            return tok  # 見つかった  

        for child in tok.lefts:  # 左の子要素から探す  
            found = self.find_children(child, pos_)  # 再帰検索  
            if found:  
                return found  # 見つかった  

        for child in tok.rights:  # 右の子要素から探す  
            found = self.find_children(child, pos_)  # 再帰検索  
            if found:  
                return found  # 見つかった  

        return None  # 見つからなかった  

    def find_imi_key(self, key):  
        """  
        related_imi_keysを引数keyで参照する  
        """  
        if key in self.related_imi_keys.keys():  
            return self.related_imi_keys[key]  
        return None  

    def find_imi(self, key):  
        """  
        imi_dbを引数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('高いスカイツリーと海が美しい', '高い = スカイツリー')  
        self.eq('高い海とスカイツリーが美しい', '高い = スカイツリー')  
        self.eq('高い海と大地が美しい', None)  # 該当する単語なし  
        self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー')  

        # 海は広いがスカイツリーが広いは変  
        # その辺りの解釈の解決  
        self.eq('広いスカイツリーと海が美しい', '広い = 海')  
        self.eq('広い海とスカイツリーが美しい', '広い = 海')  
        self.eq('広いスカイツリーとお猪口が美しい', None)  # 該当する単語なし  

        # スカイツリーと海は両方とも美しい  
        self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海')  
        self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー')  

        # 微妙なケース  
        # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、  
        # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう  
        # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、  
        # 優先順位を付けていないので↓のような結果になる  
        # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない  
        self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')  

ソースコードの解説

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

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

スクリプトの冒頭で必要モジュールをインポートしておきます。
今回は自然言語処理でspaCyを使うためspacyを。
それから単体テスト用にunittestをインポートします。

import spacy  
import unittest  

GiNZAのモデルをロード

spacy.load()ja_ginzaを指定するとGiNZAのモデルをロードすることができます。
これの返り値はginza.Japaneseになります。
返り値には慣例的にnlpと命名します。
このnlpにテキストを渡すと解析を行うことができます。

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

Analyzerの作成

Analyzerクラスを作成します。
今回のメインの処理はこのクラスのメソッドに実装します。
__init__()で意味データベースなどを初期化しておきます。

理屈ではこの意味データベースを充実させれば解析器の対応できる単語が増えますが、これを手作業で整えるのはかなり大変かと思います。

class Analyzer:  
    def __init__(self):  
        # 意味データベース  
        # 単語の意味が保存されている  
        self.imi_db = {  
            'スカイツリー': {  # スカイツリーと言う単語は  
                '高さ': '高い',  # 高いし  
                '広さ': None,  # 広さは無いけど  
                '美しさ': '美しい',  # 美しい  
            },  
            '海': {  # 海と言う単語は  
                '高さ': None,  # 高さは無いけど  
                '広さ': '広大',  # 広大で  
                '美しさ': '美しい',  # 美しい  
            },  
        }  

        # 関連する意味キー  
        # 「高い」という単語から「高さ」という意味を連想させる  
        self.related_imi_keys = {  
            '高い': '高さ',  
            '広い': '広さ',  
            '美しい': '美しさ',  
        }  

analyze()でテキストを解析する

analyze()メソッドは引数textを解析して、結果の文字列を生成します。
内部ではnlp()textを渡してdocにしています。
docspacy.tokens.doc.Docです。これはトークン列を抽象化したオブジェクトで、spaCyの解析結果がこのオブジェクトの中に詰まっています。

doc.sentsを参照すると文を1文ずつ取り出すことができます。これをfor文で回し1文(sent)を取り出しています。
このsentanalyze_sent()で解析し、その結果が真であればformat_result()で結果を整形して返しています。

    def analyze(self, text):  
        """  
        引数textを解析して返答を生成する  
        """  
        doc = nlp(text)  # spaCyで解析する  

        for sent in doc.sents:  # 文を取り出す  
            result = self.analyze_sent(sent)  # 文ごとに解析する  
            if result:  # 解析に成功した  
                return self.format_result(result)  # 結果を文字列に整形して返す  

        return None  # 解析に失敗した  

analyze_sent()で文を解析する

analyze_sent()は引数sentを解析して結果を辞書で返します。
このsentspacy.tokens.span.Spanです。
これをfor文で回すと、トークンを取り出すことができます。これはtokのことで、spacy.tokens.token.Tokenです。

トークンの属性pos_にはそのトークンの品詞が保存されています。この値が「NOUN」だったら名詞で、「PROPN」だったら「固有名詞」になります。
「スカイツリー」や「海」という単語はこのいずれかにマッチします。つまりそれらの単語が現れたらanalyze_key_token()が呼ばれます。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        d = {}  
        for tok in sent:  
            # 名詞(NOUN)か固有名詞(PROPN)を見つけたら  
            if tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN':  
                self.analyze_key_token(d, tok)  # 解析開始  

        return d  

format_result()で結果を生成

解析の結果は辞書に保存しますが、format_result()はその辞書を文字列に整形します。
辞書のキーを左辺にして、値を右辺に並べます。
すると「美しい = スカイツリー,海」のような文字列に整形されます。

    def format_result(self, d):  
        """  
        引数dを文字列に整形する  
        dは { 高い: [スカイツリー, エッフェル塔] } のようなフォーマットで保存されている  
        これを文字列「高い = スカイツリー,エッフェル塔」のようにする  
        """  
        s = ''  
        for key in d.keys():  
            s += key + ' = ' + ','.join(d[key]) + '\n'  
        s = s.rstrip()  
        return s    

analyze_key_token()でキートークンを解析する

analyze_key_token()はコア部分です。
ここでは引数tok(これは「スカイツリー」や「海」など)から品詞がADJのトークンをfind_children()で探します。
形容詞が見つかったらそのtextをキーにしてfind_imi_key()related_imi_keysを参照します。
意味キーが見つかったらtoktextをもとに意味データベースをfind_imi()で参照します。つまり単語の意味を参照します。
トークンのtext属性には元の文章のそのままの表記が保存されています。
これの意味から意味キーを元に「高い」とか「美しい」とかの意味を取得します。
意味が参照出来たら引数dに形容詞をキーにしてトークンのtextを保存します。

    def analyze_key_token(self, d, tok):  
        """  
        引数tokを解析して結果を引数dに保存する  
        """  
        # ADJ(形容詞)を子要素から探す  
        adj = self.find_children(tok, 'ADJ')  
        if not adj:  
            return  # ADJが見つからない  

        # 形容詞をキーにしてrelated_imi_keysからキーを探す  
        imi_key = self.find_imi_key(adj.text)  
        if not imi_key:  
            return  # 見つからない  

        # 単語をキーにして意味を探す  
        imi = self.find_imi(tok.text)  
        if not imi:  
            return  # 見つからない  

        # 意味キーで意味を参照する  
        value = imi[imi_key]  
        if not value:  
            return  # 意味が見つからない  

        # 形容詞をキーにして辞書に単語を保存  
        if adj.text not in d.keys():  
            d[adj.text] = [tok.text]  
        else:  
            d[adj.text].append(tok.text)  

find_children()でトークンを探す

find_children()は引数tokの依存構造を辿り、引数pos_にマッチする品詞を持つトークンを返します。
見つからなかったらNoneを返します。

トークンのlefts属性、それからrights属性にはそれぞれトークンの依存構造上における左の子要素と右の子要素が入っています。
これらの属性を再帰的に検索することで、トークンの子要素から目的のトークンを探すことができます。

依存構造とは構文解析の結果生成される木構造のことです。これは単語間の係り受けの関係の構造で、この木構造を辿ることでどの単語がどの単語に係っているのかを調べることができます。

    def find_children(self, tok, pos_):  
        """  
        tokの子要素から再帰的にpos_にヒットする単語を探す  
        """  
        if tok.pos_ == pos_:  
            return tok  # 見つかった  

        for child in tok.lefts:  # 左の子要素から探す  
            found = self.find_children(child, pos_)  # 再帰検索  
            if found:  
                return found  # 見つかった  

        for child in tok.rights:  # 右の子要素から探す  
            found = self.find_children(child, pos_)  # 再帰検索  
            if found:  
                return found  # 見つかった  

        return None  # 見つからなかった  

find_imi_key()で意味キーを探す

find_imi_key()は引数keyをキーにしてrelated_imi_keysを参照します。
値があればその値を返し、値が無ければNoneを返します。

    def find_imi_key(self, key):  
        """  
        related_imi_keysを引数keyで参照する  
        """  
        if key in self.related_imi_keys.keys():  
            return self.related_imi_keys[key]  
        return None  

find_imi()で意味を探す

find_imit()は引数keyをキーにしてimi_dbを参照します。
意味があればその意味を返し、意味がなければNoneを返します。

    def find_imi(self, key):  
        """  
        imi_dbを引数keyで参照する  
        """  
        if key in self.imi_db.keys():  
            return self.imi_db[key]  
        return None  

テストケースを書く

テストケースを書きます。
unittest.TestCaseを継承したクラスを作り、test_で始まるメソッドを定義します。
このtest_で始まるメソッドがテスト実行時に実行されます。

eq()はユーティリティーで、引数aを解析器で解析し、その結果cを引数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('高い海と大地が美しい', None)  # 該当する単語なし  
        self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー')  

        # 海は広いがスカイツリーが広いは変  
        # その辺りの解釈の解決  
        self.eq('広いスカイツリーと海が美しい', '広い = 海')  
        self.eq('広い海とスカイツリーが美しい', '広い = 海')  
        self.eq('広いスカイツリーとお猪口が美しい', None)  # 該当する単語なし  

        # スカイツリーと海は両方とも美しい  
        self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海')  
        self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー')  

        # 微妙なケース  
        # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、  
        # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう  
        # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、  
        # 優先順位を付けていないので↓のような結果になる  
        # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない  
        self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')  

おわりに

今回はspaCyを使ってスカイツリーと海はどっちが高いのかを解析してみました。
解析自体は非常に単純な方法ですが、汎用性があるかどうかは不明です。

🦝 < スカイツリーは広いな大きいな

参考