CaboChaで目的語を抽出する【Python, 自然言語処理】

148, 2020-12-31

目次

CaboChaで目的語を抽出する

人間の話す自然言語、それは自然の中で育まれてきました。
この自然言語を解析する処理が「自然言語処理」です。
自然言語処理は複数の工程にわかれていて、その中の「構文解析」という工程は単語と単語の係り受け関係を解析します。

この記事ではPythonの構文解析ライブラリである「CaboCha(カボチャ)」を使って、文中から目的語を抽出するスクリプトを書いてみたいと思います。
具体的には↓を見ていきます。

  • 構文解析とは?
  • CaboChaとは?
  • 目的語とは?
  • 目的語を抽出するスクリプトを作る

構文解析とは?

自然言語処理は人間の話す言葉、自然言語を解析する処理です。
この自然言語処理は大きく分けて↓のような工程に分かれています。

  • 字句解析
  • 構文解析
  • 意味解析
  • 文脈解析

このうち、最も基本的な処理となるのが字句解析です。
これは文章を単語のリストに分割する処理です。
たとえば「猫が歩いた」という文は「猫 / が / 歩い / た」という単語のリストに分割することが出来ます。

その次の工程が構文解析です。
これは分割した単語が、どの単語にかかっているかなどを解析します。これは係り受け解析とも呼ばれます。
たとえば「猫が犬を笑った」という文は「猫」が「笑った」にかかっています。また「犬」も「笑った」にかかっています。

意味解析は文章の持つ意味を機械的に解析できるようにします。
たとえば「猫は犬と鳥を食べた」という文章は「猫」が「犬と鳥」を食べたのか、「猫が犬と一緒に」「鳥」を食べたのか、2つの意味で受け取れます。
このような意味を機械的に解析できるようにするのが意味解析です。

文脈解析は複数の文脈を解析します。
たとえば会話をしていて「犬」という単語が出てきたら、「ああ犬ってさっき話してたあの犬のことね」と機械的に解析できるようにするのが文脈解析です。
これは意味解析以上に難解とされていて、これを正確に表現できるシステムはまだ世界には無いと言われています。

構文解析は字句解析を前提に必要とする解析です。
そのため今回あつかうCaboChaはこの字句解析も同時に行うことが出来ます。
構文解析を行えるようになると後続の工程である意味解析や文脈解析にも続くことが出来るようになります。
最近は意味解析以上の処理はディープラーニングを使うのが一般的みたいです。

CaboChaとは?

CaboCha(カボチャ)は日本産の構文解析器です。
最終更新履歴は2015年となっています。

CaboChaを使うと文章の単語の係り受けを解析することが可能になります。
Pythonにもバインディングされていて、PythonのコードからCaboChaを使うことも可能になっています。

目的語とは?

「目的語」とは動詞の動作を受ける人や物を表す単語です。
たとえば「猫が象を笑った」では、笑われているのは象なので目的語は「象」になります。

目的語は主語と動詞と深い関係を持ちます。
今回のスクリプトの解析では、主語が存在する場合は、主語にかかっている単語を調べて、その単語に目的語がかかっていたら文中の目的語として判断するという処理を行っています。
主語が存在しない場合は目的語単独で処理をします。

目的語は動詞にかかる単語なので、動詞が存在しない場合は考慮していません。

目的語を抽出するスクリプトを作る

それではスクリプトを作ります。
スクリプトは↓のようになりました。ライセンスはMITです。

import CaboCha
import unittest


def read_subjects(tree):
    """
    treeから主語を抽出する
    read_subjectsとread_object_groupsは同じ関数にした方が早くなるが、ここでは割愛
    """
    for i in range(tree.size()):
        token = tree.token(i)
        if '助詞' in token.feature.split(','):
            if token.surface in ('が', 'は'):
                token = tree.token(i - 1)
                yield token


def read_object_groups(tree):
    """
    treeから目的語を抽出する
    """
    save = []

    for i in range(tree.size()):
        token = tree.token(i)
        if '助詞' in token.feature.split(','):
            if token.surface in ('に', 'へ', 'を'):
                token = tree.token(i - 1)
                save.append(token)
                yield save
                save = []
            elif token.surface in ('と', ):
                token = tree.token(i - 1)
                save.append(token)
            else:
                save = []


def find_object_groups_by_subject(objgroups, subject):
    """
    主語と同じチャンクにかかっている目的語グループを抽出する
    """
    if subject.chunk.link < 0:  # リンクがなければNone
        return None

    for objgroup in objgroups:
        for obj in objgroup:
            if obj.chunk.link == subject.chunk.link:
                # 同じチャンクにかかっている(=同じ動詞などにかかっている)目的語を見つけた
                yield objgroup


def parse(sentence):
    """
    sentenceを構文解析して文中の目的語のグループを抽出する
    """
    cp = CaboCha.Parser()  # パーサーを得る
    tree = cp.parse(sentence)  # 入力から構文木を生成
    subjects = list(read_subjects(tree))  # 主語のリストを作成
    objgroups = list(read_object_groups(tree))  # 目的語のリストを作成

    if not len(subjects):
        for group in objgroups:
            yield group

    for subject in subjects:
        yield from find_object_groups_by_subject(objgroups, subject)


class Test(unittest.TestCase):
    def test_parse(self):
        def eq(a, b):
            groups = parse(a)
            s = ''
            for group in groups:
                for obj in group:
                    s += obj.surface + ' '
                s = s.rstrip()
                s += '\n'

            s = s.rstrip()
            self.assertEqual(s, b)

        # 主語と目的語がある
        eq('猫は象を笑った。', '象')
        eq('象を猫は笑った。', '象')
        eq('可愛い猫は大きな象を笑った。', '象')
        eq('猫は書店で象を笑った。', '象')
        eq('猫は書店で象を7回笑った。', '象')

        eq('猫が象にぶつけた。', '象')
        eq('象に猫がぶつけた。', '象')
        eq('猫の鼻が象へ飛んだ。', '象')

        # 以下、並列のテスト
        eq('猫が象と鳥を叩いた。', '象 鳥')  
        eq('猫と犬が象と鳥を叩いた。', '象 鳥')
        eq('猫と犬が白い象と赤い鳥を叩いた。', '象 鳥')

        eq('猫が象を叩き、犬が亀を笑った。', '象\n亀')

        # 動作の対象がいない
        eq('猫は象と笑い転げた。', '')

        # 主語が無い
        eq('象を笑った。', '象')
        eq('象と猫を笑った。', '象 猫')

        # 目的語が無い
        eq('猫は笑った。', '')
        eq('猫が笑った。', '')

        # 以下失敗
        # eq('猫が象と鳥、そしてトナカイを叩いた。', '象 鳥\nトナカイ\n') 

今回のスクリプトは動作のテストにunittestモジュールを使っています。
そのため実行するにはunittestの指定が必要です。

スポンサーリンク

parse関数

まずスクリプトの入り口になるのがparse()関数です。

def parse(sentence):
    """
    sentenceを構文解析して文中の目的語のグループを抽出する
    """
    cp = CaboCha.Parser()  # パーサーを得る
    tree = cp.parse(sentence)  # 入力から構文木を生成
    subjects = list(read_subjects(tree))  # 主語のリストを作成
    objgroups = list(read_object_groups(tree))  # 目的語のリストを作成

    if not len(subjects):
        for group in objgroups:
            yield group

    for subject in subjects:
        yield from find_object_groups_by_subject(objgroups, subject)

parse()関数は引数に文字列sentenceを取ります。このsentenceを構文解析して結果をリストで返します。
返す結果は目的語のグループです。これはリストやジェネレーターの入れ子で表現されています。

parse()関数は最初にCaboCha.Parser()で↓のようにパーサーを作ります。

    cp = CaboCha.Parser()  # パーサーを得る

これがCaboChaのパーサー本体です。
それからパーサーのメソッドparse()に↓のように引数のsentenceを渡します。

    tree = cp.parse(sentence)  # 入力から構文木を生成

↑のようにすると入力から構文木を作ることが出来ます。
次に↓の部分で主語のリストと目的語のリストを作ります。

    subjects = list(read_subjects(tree))  # 主語のリストを作成
    objgroups = list(read_object_groups(tree))  # 目的語のリストを作成

↑のように作成したtreeから主語と目的語をそれぞれ抽出しておきます。
主語が空だった場合、つまり文中に目的語しか存在しなかった場合は↓のように目的語のみを返します。

    if not len(subjects):
        for group in objgroups:
            yield group

主語が存在する場合はfor文で主語を回し、主語のチャンクにマッチする目的語のグループをfind_object_groups_by_subject()関数で取得します。

    for subject in subjects:
        yield from find_object_groups_by_subject(objgroups, subject)

yieldを使っているんですが、リストに変換してたりしてちょっと無駄なところがあります。
気になった方は改造してみてください。

無駄が多い

yieldはむずかしいね

read_subjects関数

read_subjects()関数はtreeから主語を抽出します。

def read_subjects(tree):
    """
    treeから主語を抽出する
    read_subjectsとread_object_groupsは同じ関数にした方が早くなるが、ここでは割愛
    """
    for i in range(tree.size()):
        token = tree.token(i)
        if '助詞' in token.feature.split(','):
            if token.surface in ('が', 'は'):
                token = tree.token(i - 1)
                yield token

tree.size()でツリー全体のトークン数を取得できます。
そしてtree.token()に添え字を渡すとトークンを取得できます。
↑のようにするとツリー内のトークンを1つずつ辿ることが出来ます。

トークン(token)はfeatureという属性を持っています。これはカンマ区切りで並べられた品詞のリストです。
このfeatureをチェックして、「助詞」が含まれていて、かつsurface属性が「が」「は」のどちらかであれば1つ前のトークンをyieldしておきます。
surface属性は表層形と呼ばれる元の文章のそのままの表記の文字列のことです。

read_object_groups関数

read_object_groups()関数はtreeから目的語のグループを抽出します。

def read_object_groups(tree):
    """
    treeから目的語を抽出する
    """
    save = []

    for i in range(tree.size()):
        token = tree.token(i)
        if '助詞' in token.feature.split(','):
            if token.surface in ('に', 'へ', 'を'):
                token = tree.token(i - 1)
                save.append(token)
                yield save
                save = []
            elif token.surface in ('と', ):
                token = tree.token(i - 1)
                save.append(token)
            else:
                save = []

グループというのは並列を考慮した表現です。
並列と言うのはたとえば「猫と犬」や「象と鳥」などの「と」で繋げた並列表現の単語グループです。この関数はそのグループを考慮してパースします。

tree.size()tree.token()でトークンを読み込み、トークンの品詞をチェックして助詞であれば処理をします。
助詞に「に」「へ」「を」が含まれていた場合はそれの1つ前の単語を目的語と判断します。
その1つ前の単語をsaveリストに保存して、yieldします。
saveは目的語の並列のグループを表すリストです。

助詞が「と」だった場合はその1つ前のトークンを目的語と見なしてsaveに保存します。
この場合は並列なので、残りのトークンを読み込むためyieldはしません。

それ以外の助詞であればsaveをクリアしておきます。

状態遷移を使う方法のほうが拡張性が高そうな関数になりそうですが、今回はそういった実装はしていません。

状態遷移好きだねほんと

find_object_groups_by_subject関数

find_object_groups_by_subject()関数はsubjectと同じチャンクにかかっている目的語のグループを取得します。
subjectはトークンですが、トークンはchunkという属性を持っています。これは単語の係り受けを表現するオブジェクトです。
chunk.linkを参照すると、その単語の係り受けのリンクを参照できます。これは整数です。

chunk.link-1であればそのトークンはリンクが無い状態なのでreturn Noneします。
目的語のグループ(objgroups)を参照して目的語のチャンクと主語のチャンクを比較します。
チャンクのリンクが同じであれば、その目的語と主語は同じ単語にかかっているということなので、ここでは暗黙的にその単語を動詞と見なします。
そしてその目的語の所属しているグループをyieldします。

def find_object_groups_by_subject(objgroups, subject):
    """
    主語と同じチャンクにかかっている目的語グループを抽出する
    """
    if subject.chunk.link < 0:  # リンクがなければNone
        return None

    for objgroup in objgroups:
        for obj in objgroup:
            if obj.chunk.link == subject.chunk.link:
                # 同じチャンクにかかっている(=同じ動詞などにかかっている)目的語を見つけた
                yield objgroup

テスト

このスクリプト、正確にはparse()関数の動作はunittestモジュールで検証します。
↓のようなテストを書きます。

class Test(unittest.TestCase):
    def test_parse(self):
        def eq(a, b):
            groups = parse(a)
            s = ''
            for group in groups:
                for obj in group:
                    s += obj.surface + ' '
                s = s.rstrip()
                s += '\n'

            s = s.rstrip()
            self.assertEqual(s, b)

        # 主語と目的語がある
        eq('猫は象を笑った。', '象')
        eq('象を猫は笑った。', '象')
        eq('可愛い猫は大きな象を笑った。', '象')
        eq('猫は書店で象を笑った。', '象')
        eq('猫は書店で象を7回笑った。', '象')

        eq('猫が象にぶつけた。', '象')
        eq('象に猫がぶつけた。', '象')
        eq('猫の鼻が象へ飛んだ。', '象')

        # 以下、並列のテスト
        eq('猫が象と鳥を叩いた。', '象 鳥')  
        eq('猫と犬が象と鳥を叩いた。', '象 鳥')
        eq('猫と犬が白い象と赤い鳥を叩いた。', '象 鳥')

        eq('猫が象を叩き、犬が亀を笑った。', '象\n亀')

        # 動作の対象がいない
        eq('猫は象と笑い転げた。', '')

        # 主語が無い
        eq('象を笑った。', '象')
        eq('象と猫を笑った。', '象 猫')

        # 目的語が無い
        eq('猫は笑った。', '')
        eq('猫が笑った。', '')

        # 以下失敗
        # eq('猫が象と鳥、そしてトナカイを叩いた。', '象 鳥\nトナカイ\n') 

このテストを実行するには、↑のスクリプトのコードをscript.pyなどで保存し、↓のようにテストを実行します。

python -m unittest script

テストを実行すると↓のような結果になります。

.
----------------------------------------------------------------------
Ran 1 test in 0.062s

OK

テストコードを見る限りでは↓のような基本的な文はOKです。

        eq('猫は象を笑った。', '象')
        eq('象を猫は笑った。', '象')

また↓のような並列な文の場合もOKになってます。

        eq('猫が象と鳥を叩いた。', '象 鳥')  
        eq('猫と犬が象と鳥を叩いた。', '象 鳥')
        eq('猫と犬が白い象と赤い鳥を叩いた。', '象 鳥')

        eq('猫が象を叩き、犬が亀を笑った。', '象\n亀')

↑の「猫が象を叩き、犬が亀を笑った。」という文はそれぞれ動詞にかかっている目的語が違います。
そのためこれらは別グループとして処理されています。

↓のように主語が無い場合もOKです。

        eq('象を笑った。', '象')
        eq('象と猫を笑った。', '象 猫')

失敗してしまったのは↓のケースでした。

        # eq('猫が象と鳥、そしてトナカイを叩いた。', '象 鳥\nトナカイ\n') 

これは「鳥」に助詞がないためうまくパースされません。「、」を考慮すればうまくいくかもしれません。

おわりに

今回は構文解析を使って文章から目的語を抽出してみました。
書いたあとに思いましたが、目的語の抽出だけなら係り受け解析は必要ないかもしれません。
今回は主語との係り受けの関係を考慮していますが、そもそも主語にかかっている動詞にかかっていない目的語など存在するのでしょうか?
ひょっとしたら構文解析ではなく形態素解析だけで十分実現できたのでは?
うーん、構文解析の前に国語の勉強が必要かもしれません。

国語を勉強しよう

国語はAIと繋がっている

参考

2021-01-06

目的語や主語を抽出するなら、CaboChaを使うよりは、SynChaの「ヲ格」や「ガ格」を使うか、あるいはGiNZAの「obj」や「nsubj」を使った方が、いい結果が出るように思えます。

2:  narupo (管理者)
2021-01-07

>>1
コメントありがとうございます
そうなんですね、参考にさせて頂きますm(_ _)m
SynChaは前に使ったことがありますがGiNZAは使ったことがないので、試してみようかな~という感じです。

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

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

投稿する内容です。

スポンサーリンク

スポンサーリンク