PythonとCaboChaで構文解析: 誰が天才か判定する【自然言語処理】

108, 2020-11-12

目次

CaboChaで誰が天才か判定する

日本語などの自然言語を解析する処理を「自然言語処理」と言います。
自然言語処理は↓のような工程にわかれています。

  • 形態素解析

  • 構文解析

  • 意味解析

  • 文脈解析

このうち「形態素解析」と「構文解析」をまとめてやってくれるライブラリに「CaboCha」というのがあります。
PythonでもこのCaboChaを使うことできます。

この記事ではポジティブなワード、たとえば「天才」とか「秀才」が、「誰に対して」言われているかを簡単に判定するスクリプトを解説します。
この記事を読めばCaboChaの基本的な使い方がわかります。

具体的には↓のコンテンツを見ていきます。

  • 構文解析とは?

  • スクリプトの概要

  • スクリプトのコード全文

  • スクリプトの解説

構文解析とは?

自然言語処理で最初に実行されるのが形態素解析です。
これは自然言語の文字列を単語のリストに分解する処理です。

英語などは半角スペース区切りで単語が書かれているので解析が簡単ですが、日本語はそうではないので形態素解析という辞書を使った解析が必要になります。
形態素解析を行うライブラリには有名なライブラリに「MeCab」などがありますが、今回は解説は割愛します。

形態素解析の次に実行されるのが構文解析です。
構文解析は単語間の「係り受け(かかりうけ)」というのを解析します。
これはつまりどの単語がどの単語にかかっているか、ということを解析します。

CaboChaは単語間のかかり方の構造をツリーにして出力します。
そのツリーをたどることで単語の「かかり」を辿ることが可能です。

たとえば「犬が歩く」という文章は「犬」が「歩く」にかかっています。
この関係をツリー構造にするのがCaboChaの主な仕事です。

係り受けを解析することでその次の工程である意味解析に進むことが可能です。

スクリプトの概要

今回紹介するスクリプトはCaboChaによる構文解析の出力を利用したものです。
具体的には最初に「ポジティブなワード」を定義します。
これはたとえば「天才」とか「秀才」とかです。

文章の中にポジティブなワードが現れたら、そのワードが「誰に」かかっているか解析します。
そして最終的に「誰が何を言われたか」を結果として出力します。
たとえば

君はびっくりするような天才だ

という文章があったとして、これを解析して

「君」が「天才だ」と言われました

と出力します。

解析自体はそんなに複雑なことをしていませんが、CaboChaの使い方の勉強にはなると思います。

スクリプトのコード全文

今回のスクリプトのコードの全文は↓になります。
ライセンスはMITです。

"""
CaboChaの練習スクリプト
ポジティブなワードが誰に係っているか解析して出力する

ライセンス: MIT
作成日: 2020/10/15
"""
import CaboCha
import unittest


def is_positive_word(word):
    """
    wordがポジティブかどうか判定する
    """
    return word in ['天才', '秀才']


def gen_chunks(tree):
    """
    構文木treeからチャンクの辞書を生成する
    """
    chunks = {}
    key = 0  # intにしているがこれはChunk.linkの値で辿れるようにしている

    for i in range(tree.size()):  # ツリーのサイズだけ回す
        tok = tree.token(i)  # トークンを得る
        if tok.chunk:  # トークンがチャンクを持っていたら
            chunks[key] = tok.chunk  # チャンクを辞書に追加する
            key += 1

    return chunks


def get_toks_by_chunk(tree, chunk):
    """
    チャンクからトークン列を得る
    """
    beg = chunk.token_pos
    end = chunk.token_pos + chunk.token_size
    toks = []

    for i in range(beg, end):
        tok = tree.token(i)
        toks.append(tok)

    return toks


def find_pos(toks, pos):
    """
    posの品詞を持つトークンを探す
    """
    for tok in toks:
        if pos in tok.feature.split(','):
            return tok
    return None


def get_surface_from_toks(toks):
    """
    トークン列の表層形をまとめる
    """
    surface = ''
    for tok in toks:
        surface += tok.surface
    return surface


def find_positive_tok(toks):
    """
    トークン列からポジティブなトークンを探す
    """
    for tok in toks:
        if is_positive_word(tok.surface):
            return tok
    return None


def analyze_by_pronoun(tree, chunks, chunk):
    # 引数chunkからトークン列を得る
    pronoun_toks = get_toks_by_chunk(tree, chunk)
    if not len(pronoun_toks):
        return None

    # トークン列から代名詞のトークンを探す
    pronoun_tok = find_pos(pronoun_toks, '代名詞')
    if not pronoun_tok:  # 見つからなかった
        return None
    if not pronoun_tok.chunk:
        return None
    if pronoun_tok.chunk.link < 0:  # チャンクにリンクがない
        return None

    # 代名詞のチャンクにつながっているチャンクを得る
    positive_chunk = chunks[pronoun_tok.chunk.link]
    positive_toks = get_toks_by_chunk(tree, positive_chunk)  # トークン列を得る
    if not len(positive_toks):  # トークン列が空
        return None

    # トークン列から名詞のトークンを得る
    positive_tok = find_positive_tok(positive_toks)
    if not positive_tok:  # 見つからなかった
        return None

    pronoun_surface = pronoun_tok.surface  # 代名詞の表層形
    positive_surface = get_surface_from_toks(positive_toks)  # トークン列を表層形に

    # 結果を文字列として合成
    s = f'「{pronoun_surface}」が「{positive_surface}」と言われました'
    return s


def analyze_by_positive(tree, chunks, chunk):
    # チャンクからトークン列を得る
    positive_toks = get_toks_by_chunk(tree, chunk)
    if not len(positive_toks):
        return None

    # ポジティブなトークンを探す
    positive_tok = find_positive_tok(positive_toks)
    if not positive_tok:
        return None
    if not positive_tok.chunk:
        return None
    if positive_tok.chunk.link < 0:
        return None

    # ポジティブなトークンにつながっているチャンクを得る
    pronoun_chunk = chunks[positive_tok.chunk.link]

    # チャンクからトークン列を得る
    pronoun_toks = get_toks_by_chunk(tree, pronoun_chunk)
    if not len(pronoun_toks):
        return None

    # トークン列から代名詞のトークンを探す
    pronoun_tok = find_pos(pronoun_toks, '代名詞')
    if not pronoun_tok:
        return None

    pronoun_surface = pronoun_tok.surface  # 代名詞の表層形
    positive_surface = get_surface_from_toks(positive_toks)  # ポジティブなトークン列を表層形に

    # 結果を文字列として合成
    s = f'「{pronoun_surface}」が「{positive_surface}」と言われました'
    return s


class Test(unittest.TestCase):
    def eq(self, a, b):
        cp = CaboCha.Parser()  # パーサー
        tree = cp.parse(a)  # 構文木を構築

        # チャンクの辞書を作成
        chunks = gen_chunks(tree)

        result = ''
        for chunk in chunks.values():
            # 最初に代名詞を優先して解析する
            r = analyze_by_pronoun(tree, chunks, chunk)
            if r:
                result += r
                continue

            # 代名詞でだめならポジティブなトークンを優先して解析する
            r = analyze_by_positive(tree, chunks, chunk)
            if r:
                result += r
                continue

        # 結果をテスト
        self.assertEqual(result, b)

    def test_analyze(self):
        # ok
        self.eq('君はびっくりするような天才だ', '「君」が「天才だ」と言われました')
        self.eq('僕はすごい秀才だ', '「僕」が「秀才だ」と言われました')
        self.eq('天才だ君は', '「君」が「天才だ」と言われました')
        self.eq('すごいな、秀才だな、君は', '「君」が「秀才だな、」と言われました')

        # fail
        # self.eq('僕は君が天才だと思う', '')
        # self.eq('君のピアノの腕は天才だと思う', '')

スクリプトの実行方法

↓のコードをanalyze.pyなどのファイルに保存し、↓のコマンドを実行するとスクリプトが実行されます。

python -m unittest analyze

実行結果↓。

.
----------------------------------------------------------------------
Ran 1 test in 0.015s

OK

スクリプトの解説

スクリプトの解説です。

ポジティブなワードかどうかの評価

まずポジティブなワードかどうかを評価する関数を見てみます。
これはis_positive_word()関数を使っています。

def is_positive_word(word):
    """
    wordがポジティブかどうか判定する
    """
    return word in ['天才', '秀才']

内容的には引数wordがリストの中に入っているか判定してるだけです。

チャンクの辞書の生成

CaboChaの主要なデータに「ツリー」と「チャンク」というものがあります。

ツリーはCaboCha.Treeクラスのオブジェクトです。
これは構文解析の結果の構文木を表すオブジェクトです。

チャンクはCaboCha.Chunkクラスのオブジェクトです。
このクラスは単語の「係り方」を表現するクラスです。

↓の関数gen_chunks()は引数のtreeから、チャンクの辞書を生成します。
なぜチャンクの辞書を生成するのかというと、あとでチャンクを辿る処理を書くためです。

CaboCha.Treeであるtreeはメソッドsize()を持っていますが、これを使うと構文木内のトークン列のサイズを取得できます。
size()分だけ、for文を回し、ツリーのtoken()メソッドにインデックスを渡すと、トークンを取得できます。

トークンとはCaboCha.Tokenのことで、これもCaboChaの主要なデータの1つです。
これは形態素解析、そして構文解析された結果のトークン(字句、形態素)です。
CaboCha.Tokenchunkという属性を持っていて、これはチャンクのことです。
ツリーからトークン、トークンからチャンクを辿れるわけですね。

トークンがチャンクを持っていたら辞書にチャンクを追加します。

def gen_chunks(tree):
    """
    構文木treeからチャンクの辞書を生成する
    """
    chunks = {}
    key = 0  # intにしているがこれはChunk.linkの値で辿れるようにしている

    for i in range(tree.size()):  # ツリーのサイズだけ回す
        tok = tree.token(i)  # トークンを得る
        if tok.chunk:  # トークンがチャンクを持っていたら
            chunks[key] = tok.chunk  # チャンクを辞書に追加する
            key += 1

    return chunks

チャンクからトークン列を取得する

get_toks_by_chunk()はツリーとチャンクから、チャンクの所属するトークン列を取得する関数です。
CaboCha.Chunkは属性token_posを持っていて、これはツリー内におけるチャンクの所属するトークンのインデックスを表しています。
token_sizeはチャンクの所属するトークン列のサイズです。
これらの値を使ってfor文を回すことでCaboCha.Treeからトークン列を取得することができます。

def get_toks_by_chunk(tree, chunk):
    """
    チャンクからトークン列を得る
    """
    beg = chunk.token_pos
    end = chunk.token_pos + chunk.token_size
    toks = []

    for i in range(beg, end):
        tok = tree.token(i)
        toks.append(tok)

    return toks

特定の品詞を持つトークンをトークン列から探す

find_pos()関数はトークン列である引数toksから、引数posの品詞を持つトークンを探す関数です。
CaboCha.Tokenは属性featureを持っていて、これに品詞がカンマ区切りの文字列で保存されています。
これをsplit()で分割し、その中にposがあるか判定することでトークンが品詞を持っているかどうか判定しています。

def find_pos(toks, pos):
    """
    posの品詞を持つトークンを探す
    """
    for tok in toks:
        if pos in tok.feature.split(','):
            return tok
    return None

トークン列の表層形をまとめる

get_surface_from_toks()関数はトークン列である引数toksが持つトークンの表層形を文字列としてまとめる関数です。
CaboCha.Tokenは属性surfaceを持っていて、これは「表層形」と呼ばれるデータです。
この表層形は形態素解析時に作成されるもので、元の文章のそのままの表記の文字列です。

def get_surface_from_toks(toks):
    """
    トークン列の表層形をまとめる
    """
    surface = ''
    for tok in toks:
        surface += tok.surface
    return surface

ポジティブなワードを持つトークンを探す

find_positive_tok()はトークン列である引数toksの中から、ポジティブなワード(表層形)を持つトークンを探す関数です。
先ほどのis_positive_word()関数を内部で使っています。

def find_positive_tok(toks):
    """
    トークン列からポジティブなトークンを探す
    """
    for tok in toks:
        if is_positive_word(tok.surface):
            return tok
    return None

代名詞を優先した解析

analyze_by_pronoun()関数は実際に「誰がポジティブなワードを言われているか」を解析する関数の1つです。
この関数は「代名詞」を優先して解析します。

関数の引数treeにはCaboCha.Tree, 引数chunksにはチャンクの辞書、引数chunkにはループで参照中のチャンクを渡します。

この関数は成功時に解析結果の文字列、失敗時にNoneを返します。

関数はまず最初にchunkからトークン列を得ます。
そのあとにトークン列から代名詞のトークンを探します。
代名詞のトークンが見つからない場合はNoneを返します。

代名詞のトークンが見つかったら、そのトークンのチャンクが持つリンクを使ってchunks内のチャンクを参照します。
CaboCha.Chunkは属性linkを持っていて、これは整数です。リンクが有効な場合は0以上、無効な場合は-1が格納されます(おそらく。ドキュメントがないため不明)。
このlinkchunksのキーを整数にしていた理由です。

チャンクが見つかったらそのチャンクのトークン列を得ます。
このトークン列からポジティブなワードを持つトークンを探します。
ポジティブなトークンが見つからなかったらNoneを返します。

最後に代名詞のトークンとポジティブなトークンの表層形をまとめて、文字列として結果を返しています。

def analyze_by_pronoun(tree, chunks, chunk):
    # 引数chunkからトークン列を得る
    pronoun_toks = get_toks_by_chunk(tree, chunk)
    if not len(pronoun_toks):
        return None

    # トークン列から代名詞のトークンを探す
    pronoun_tok = find_pos(pronoun_toks, '代名詞')
    if not pronoun_tok:  # 見つからなかった
        return None
    if not pronoun_tok.chunk:
        return None
    if pronoun_tok.chunk.link < 0:  # チャンクにリンクがない
        return None

    # 代名詞のチャンクにつながっているチャンクを得る
    positive_chunk = chunks[pronoun_tok.chunk.link]
    positive_toks = get_toks_by_chunk(tree, positive_chunk)  # トークン列を得る
    if not len(positive_toks):  # トークン列が空
        return None

    # トークン列から名詞のトークンを得る
    positive_tok = find_positive_tok(positive_toks)
    if not positive_tok:  # 見つからなかった
        return None

    pronoun_surface = pronoun_tok.surface  # 代名詞の表層形
    positive_surface = get_surface_from_toks(positive_toks)  # トークン列を表層形に

    # 結果を文字列として合成
    s = f'「{pronoun_surface}」が「{positive_surface}」と言われました'
    return s

ポジティブなワードを優先した解析

analyze_by_positive()関数は先程のanalyze_by_pronoun()関数と違い、ポジティブなトークンを優先して検索します。
やってることはanalyze_by_pronoun()とほとんど同じです。

def analyze_by_positive(tree, chunks, chunk):
    # チャンクからトークン列を得る
    positive_toks = get_toks_by_chunk(tree, chunk)
    if not len(positive_toks):
        return None

    # ポジティブなトークンを探す
    positive_tok = find_positive_tok(positive_toks)
    if not positive_tok:
        return None
    if not positive_tok.chunk:
        return None
    if positive_tok.chunk.link < 0:
        return None

    # ポジティブなトークンにつながっているチャンクを得る
    pronoun_chunk = chunks[positive_tok.chunk.link]

    # チャンクからトークン列を得る
    pronoun_toks = get_toks_by_chunk(tree, pronoun_chunk)
    if not len(pronoun_toks):
        return None

    # トークン列から代名詞のトークンを探す
    pronoun_tok = find_pos(pronoun_toks, '代名詞')
    if not pronoun_tok:
        return None

    pronoun_surface = pronoun_tok.surface  # 代名詞の表層形
    positive_surface = get_surface_from_toks(positive_toks)  # ポジティブなトークン列を表層形に

    # 結果を文字列として合成
    s = f'「{pronoun_surface}」が「{positive_surface}」と言われました'
    return s

スクリプトの実行(テスト)

今回のスクリプトの実行にはunittestモジュールを使っています。
このモジュールの使用にはimport unittestが必要です。

unittestモジュールのTestCaseクラスを継承したクラスTestを作成します。
このTestクラスにeq()メソッドとtest_analyze()メソッドを定義します。

class Test(unittest.TestCase):
    def eq(self, a, b):
        cp = CaboCha.Parser()  # パーサー
        tree = cp.parse(a)  # 構文木を構築

        # チャンクの辞書を作成
        chunks = gen_chunks(tree)

        result = ''
        for chunk in chunks.values():
            # 最初に代名詞を優先して解析する
            r = analyze_by_pronoun(tree, chunks, chunk)
            if r:
                result += r
                continue

            # 代名詞でだめならポジティブなトークンを優先して解析する
            r = analyze_by_positive(tree, chunks, chunk)
            if r:
                result += r
                continue

        # 結果をテスト
        self.assertEqual(result, b)

    def test_analyze(self):
        # ok
        self.eq('君はびっくりするような天才だ', '「君」が「天才だ」と言われました')
        self.eq('僕はすごい秀才だ', '「僕」が「秀才だ」と言われました')
        self.eq('天才だ君は', '「君」が「天才だ」と言われました')
        self.eq('すごいな、秀才だな、君は', '「君」が「秀才だな、」と言われました')

        # fail
        # self.eq('僕は君が天才だと思う', '')
        # self.eq('君のピアノの腕は天才だと思う', '')

eq()メソッド内で実際に解析を行っています。
CaboChaで構文解析を実行するにはまず最初にCaboCha.Parserクラスからオブジェクトを作成します。
このオブジェクトにparse()メソッドがあるので、文字列を指定して実行すると構文解析が行われます。
その結果はCaboCha.Treeクラスのオブジェクトとして返ってきます。

次に作ったツリーからチャンクの辞書を作ります。
あとはこの辞書をfor文で回し、analyze_by_pronoun()関数やanalyze_by_positive()関数を呼び出します。
analyze_by_pronoun()関数を最初に呼び出し、結果がNoneだったら次にanalyze_by_positive()関数を呼び出しています。

ループの後にself.assertEqual(result, b)でテストを実行します。
このテストはresultbが一致しているかどうかのテストです。
一致してなければテストが失敗し、画面にエラーが出力されます。

    def eq(self, a, b):
        cp = CaboCha.Parser()  # パーサー
        tree = cp.parse(a)  # 構文木を構築

        # チャンクの辞書を作成
        chunks = gen_chunks(tree)

        result = ''
        for chunk in chunks.values():
            # 最初に代名詞を優先して解析する
            r = analyze_by_pronoun(tree, chunks, chunk)
            if r:
                result += r
                continue

            # 代名詞でだめならポジティブなトークンを優先して解析する
            r = analyze_by_positive(tree, chunks, chunk)
            if r:
                result += r
                continue

        # 結果をテスト
        self.assertEqual(result, b)

test_analyze()関数には実際のテストを書きます。
eq()メソッドの第1引数には入力となる文字列、第2引数には期待する出力を渡します。

↓のように最初の4行のテストは成功しますが、その後の2行のテストは失敗します。
今回の解析では後半の2行の解析はうまくいきませんでした。
しかし前半の4行の解析はうまくいっています。

    def test_analyze(self):
        # ok
        self.eq('君はびっくりするような天才だ', '「君」が「天才だ」と言われました')
        self.eq('僕はすごい秀才だ', '「僕」が「秀才だ」と言われました')
        self.eq('天才だ君は', '「君」が「天才だ」と言われました')
        self.eq('すごいな、秀才だな、君は', '「君」が「秀才だな、」と言われました')

        # fail
        # self.eq('僕は君が天才だと思う', '')
        # self.eq('君のピアノの腕は天才だと思う', '')

テスト内容を見ると

君はびっくりするような天才だ

という入力は、解析されると

「君」が「天才だ」と言われました

という出力になっています。
「君は」の「君」が「天才だ」に係っているため、この結果は期待通りの結果です。

おわりに

CaboChaを使って構文解析を行うとこのスクリプトのように比較的に簡単に自然言語を解析することができます。
形態素解析から構文解析にステップアップするにはこういった小さいスクリプトを書くのが良さそうです。

(^ _ ^)

構文解析で天才をあぶり出そう

関連記事



この記事のアンケートを送信する