spaCyで名詞に係る動詞を抽出する【自然言語処理, Python】

203, 2021-03-11

目次

spaCyで名詞に係る動詞を抽出する

私たちが使う言葉は「自然言語」と言われていますが、この自然言語を解析するのが「自然言語処理」です。
自然言語処理を行えるライブラリは多数ありますが、PythonにはspaCyという自然言語処理ライブラリがあります。

今回はこのspaCyを使って日本語の文章から、「名詞に係っている動詞」を抽出してみたいと思います。

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

  • spaCyとは?

  • spaCyの名詞と動詞について

  • プログラムの実行結果

  • プログラムの設計

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

  • ソースコードの解説

spaCyとは?

spaCyはPython製の自然言語処理ライブラリです。

オープンソースで開発されており、MITライセンスで利用することができます。
さまざまな言語の学習済み統計モデルを搭載していて、モデルをロードすることで簡単に利用することができます。
それらのモデルを利用して自然言語を解析することが可能です。

近年まで日本語の解析に弱かったspaCyですが、GiNZAの登場によって日本語の解析が簡単に可能になりました。
GiNZAとはリクルートと国立国語研究所が共同で開発した日本語を扱える自然言語処理ライブラリです。

spaCyによる日本語の解析では、GiNZAのモデルをロードして解析するのが一般的な流れになります。

spaCyの名詞と動詞について

spaCyでは解析したデータをトークン列として扱います。
トークン列とは単語のリストのことです。
たとえば「猫が笑う」という文章をトークン列に変換すると「猫 / が / 笑う」のようになります。
それぞれの単語は情報を持っていて、その単語の情報を参照することでその単語を解析することができます。

その単語の情報の1つがトークンの持つpos_属性です。
これは単語の品詞を表す属性です。
品詞とは単語の分類のことで、名詞や動詞などがそれに当たります。

たとえばトークンのpos_VERBだった場合、その単語は動詞になります。
いっぽうpos_NOUNだった場合、その単語は名詞になります。

プログラムの解析ではこのpos_属性をひんぱんに参照します。

プログラムの実行結果

後述のプログラムのソースコードをsample.py等に保存し、python -m unittest sampleとやって単体テストを実行すると↓のような結果になります。

.
----------------------------------------------------------------------
Ran 1 test in 0.993s

OK

↑の出力はテストがすべて通過したことを表しています。
テストの詳細は後述のソースコードの解説を見て頂くとして、テスト内容は↓のようになります。

        # うまくいくケース
        self.eq('猫は走った。', '猫 = 走る')
        self.eq('可愛い猫は走った。', '猫 = 走る')
        self.eq('猫は走り去った。', '猫 = 走り去る')
        self.eq('可愛い猫は華麗に走り去った。', '猫 = 走り去る')
        self.eq('猫と犬は走った。', '猫, 犬 = 走る')
        self.eq('走った猫が。', '猫 = 走る')
        self.eq('走る猫。', '猫 = 走る')
        self.eq('やみくもに走る猫。', '猫 = 走る')
        self.eq('笑う猫は走った。', '猫 = 笑う, 走る')

        # うまくいかないケース
        self.eq('猫は元気よく走った。', '元気, 猫 = 走る')  # 「元気」が名詞扱い
        self.eq('犬は走り猫は笑った。', '走り, 猫, 犬 = 笑う')  # 「走り」が名詞扱い(CaboChaでも同じ)

↑のテストでは「猫は走った。」という入力から「猫 = 走る」という出力が生成されています。
「うまくいくケース」ではテストが期待通りの結果になっていますが、「うまくいかないケース」では疑問符の付く結果となっています。
「猫は元気よく走った。」という入力からは「猫 = 走る」という出力を期待したいところですが、現実の結果は「元気, 猫 = 走る」になっています。
これは「元気」という単語が名詞になっているためです。

プログラムの設計

今回作るプログラムの設計についてです。

プログラムの動作テストは単体テストで行います。
これはPythonのunittestモジュールを使います。

spaCyで日本語の文章を解析してdocオブジェクトに変換し、トークン列にします。
そしてそのトークン列を回して、名詞と動詞のトークンを探します。
名詞のトークンが見つかったら、そのトークンに係っている動詞のトークン列を探します。
動詞のトークンの場合は、そのトークンに係っている名詞のトークンを探します。

トークン列が見つかったらそれぞれ保存し、最後にトークン列をテキストに変換します。
テキストのフォーマットは↓のようになります。

名詞 = 動詞

基本形のフォーマット↑ですが↓のフォーマットにも対応します。

名詞, 名詞 = 動詞
名詞 = 動詞, 動詞
名詞, 名詞 = 動詞, 動詞

これは名詞と動詞が複数ある場合があるからです。
そのため↑のようなフォーマットにして対応しています。

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

↓がプログラムのソースコードです。
繰り返しになりますが、このプログラムを実行するには↓のソースコードをsample.pyなどに保存し、python -m unittest sampleを実行してください。

"""
入力された文章の名詞に係っている動詞を抽出する

Licence: MIT
Created at: 2021/01/29
"""
import spacy
import unittest


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


class Analyzer:
    def analyze(self, sentence):
        """
        sentenceを解析して名詞と動詞を抽出する
        """
        doc = nlp(sentence)
        verbs = []  # 動詞のリスト
        nouns = []  # 名詞のリスト

        for tok in doc:
            if tok.pos_ == 'VERB':
                # 動詞に係っている名詞を集める
                self.collect_nouns_from_children(nouns, tok)
                if len(nouns):
                    verbs.append(tok)
                    break
            elif tok.pos_ == 'NOUN':
                # 名詞に係っている動詞を集める
                verb = self.find_verb_from_children(tok)
                if verb:
                    verbs.append(verb)
                    nouns.append(tok)

        if not len(verbs):
            return ''  # 動詞が空なら抽出失敗

        # 重複したトークンを除く
        nouns = self.unique(nouns)
        verbs = self.unique(verbs)

        # トークン列を文字列に整形
        s = ', '.join([tok.text for tok in nouns[::-1]])
        s += ' = ' + ', '.join([tok.lemma_ for tok in verbs])
        return s

    def unique(self, toks):
        """
        toksから重複したトークンを除く
        """
        dst = []
        def has(arg):
            for t in dst:
                if t == arg:
                    return True
            return False

        for tok in toks:
            if not has(tok):
                dst.append(tok)

        return dst

    def find_verb_from_children(self, tok):
        """
        tok.childrenから再帰的にverbを見つける
        """
        if tok.pos_ == 'VERB':
            return tok

        for t in tok.children:
            found = self.find_verb_from_children(t)
            if found:
                return found

        return None

    def collect_nouns_from_children(self, nouns, tok):
        """
        tok.childrenから再帰的に名詞を集める
        """
        if tok.pos_ == 'NOUN':
            nouns.append(tok)

        for t in tok.children:
            self.collect_nouns_from_children(nouns, t)


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('やみくもに走る猫。', '猫 = 走る')
        self.eq('笑う猫は走った。', '猫 = 笑う, 走る')

        # うまくいかないケース
        self.eq('猫は元気よく走った。', '元気, 猫 = 走る')  # 「元気」が名詞扱い
        self.eq('犬は走り猫は笑った。', '走り, 猫, 犬 = 笑う')  # 「走り」が名詞扱い(CaboChaでも同じ)

ソースコードの解説

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

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

日本語の文章の解析にspaCyを使うのでspacyをインポートします。
それから単体テスト用にunittestをインポートしておきます。

import spacy
import unittest

GiNZAモデルのロード

グローバル変数nlpを作成します。

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

spacy.load()は統計モデルをロードします。
ja_ginzaを指定していますが、これはGiNZAのモデルです。
このモデルをロードするとspaCyで日本語の文章を解析できます。
spacy.load()の返り値はginza.Japaneseです。

nlpをグローバル変数にしているのは、この変数は複数のクラスから使用される可能性があるからです。
nlp()に文章を渡すとその文章を解析することが可能です。

Analyzerクラスの作成

今回のプログラムの主な処理はAnalyzerクラスで行います。

class Analyzer:
    ...

メソッドは後述します。

analyze()で文章を解析

解析の本体はanalyze()メソッドです。
analyze()は引数のsentencenlp()で解析して、docにします。
これはspacy.tokens.doc.Docです。

docはトークン列として扱うことが可能です。そのためfor文で回すことができます。
docfor文で回すとトークンを取り出せます。
このトークンが動詞(VERB)または名詞(NOUN)だった場合に処理が分岐します。
VERBの場合はそのトークンに係っている名詞のトークン列をcollect_nouns_from_children()で集めます。
NOUNの場合は動詞のトークンをfind_verb_from_children()で探します。

VERBの場合は名詞が見つかった場合はループからbreakしてます。
NOUNの場合はループを続行してます。
この違いは文脈の解釈的にこっちのほうが良い精度が出るからです。

verbsnounsのトークン列を保存したら、そのトークン列から重複したトークンを除きます。

重複したトークンを除いたらそのトークン列を使ってフォーマットのテキストを生成します。
あとはそれをreturnして終わりです。

    def analyze(self, sentence):
        """
        sentenceを解析して名詞と動詞を抽出する
        """
        doc = nlp(sentence)
        verbs = []  # 動詞のリスト
        nouns = []  # 名詞のリスト

        for tok in doc:
            if tok.pos_ == 'VERB':
                # 動詞に係っている名詞を集める
                self.collect_nouns_from_children(nouns, tok)
                if len(nouns):
                    verbs.append(tok)
                    break
            elif tok.pos_ == 'NOUN':
                # 名詞に係っている動詞を集める
                verb = self.find_verb_from_children(tok)
                if verb:
                    verbs.append(verb)
                    nouns.append(tok)

        if not len(verbs):
            return ''  # 動詞が空なら抽出失敗

        # 重複したトークンを除く
        nouns = self.unique(nouns)
        verbs = self.unique(verbs)

        # トークン列を文字列に整形
        s = ', '.join([tok.text for tok in nouns[::-1]])
        s += ' = ' + ', '.join([tok.lemma_ for tok in verbs])
        return s

collect_nouns_from_children()で名詞を集める

collect_nouns_from_children()は引数tokchildrenから再帰的に名詞を集めます。
トークンの属性childrenはそのトークンに係っているトークン列を表します。
このchildrenを辿ることで依存構造のツリーを辿ることができます。
名詞のトークンは引数nounsに保存されます。

    def collect_nouns_from_children(self, nouns, tok):
        """
        tok.childrenから再帰的に名詞を集める
        """
        if tok.pos_ == 'NOUN':
            nouns.append(tok)

        for t in tok.children:
            self.collect_nouns_from_children(nouns, t)

find_verb_from_children()で動詞を見つける

find_verb_from_children()は引数tokchildrenを再帰的に検索して動詞のトークンを返します。
動詞のトークンが見つかった場合はその時点で再帰を終了します。
見つからなかった場合はNoneを返します。

    def find_verb_from_children(self, tok):
        """
        tok.childrenから再帰的にverbを見つける
        """
        if tok.pos_ == 'VERB':
            return tok

        for t in tok.children:
            found = self.find_verb_from_children(t)
            if found:
                return found

        return None

unique()で重複したトークンを除く

unique()メソッドは引数toksから重複したトークンを除きます。
内部関数のhas()は引数argdstの中に含まれていたらTrueを返します。
has()でトークンがdstに含まれているか調べて、含まれていなかったらdstに追加するという具合です。

    def unique(self, toks):
        """
        toksから重複したトークンを除く
        """
        dst = []
        def has(arg):
            for t in dst:
                if t == arg:
                    return True
            return False

        for tok in toks:
            if not has(tok):
                dst.append(tok)

        return dst

単体テストを書く

今回はmain関数などは定義しておらず、動作のテストは↓の単体テストで行います。
単体テストはunittest.TestCaseを継承したクラスを作ります。
そしてtest_で始めるメソッドを定義します。このメソッドがテストで実行されます。

eq()Analyzerをオブジェクトにして引数aを渡してanalyze()を実行します。
その結果cを引数bassertEqual()で比較します。
assertEqual()TestCaseのメソッドで、これは第1引数と第2引数が等しいかどうかチェックするメソッドです。
等しくなかったらエラーになります。

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('やみくもに走る猫。', '猫 = 走る')
        self.eq('笑う猫は走った。', '猫 = 笑う, 走る')

        # うまくいかないケース
        self.eq('猫は元気よく走った。', '元気, 猫 = 走る')  # 「元気」が名詞扱い
        self.eq('犬は走り猫は笑った。', '走り, 猫, 犬 = 笑う')  # 「走り」が名詞扱い(CaboChaでも同じ)

おわりに

今回はspaCyを使って名詞に係る動詞を抽出するというプログラムを作ってみました。
このロジックを上手く使えば意味解析に発展できるかと思います。

AIに猫の状態を把握させたりとかね

AIの状態だよね

AIの脳にcat_stateという変数を作っておくと

それでこの解析でcat_stateを変化させれば……

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。