ユーニックス総合研究所

  • home
  • archives
  • python-cabocha-mine

CaboChaで構文解析して「私の~」を表示する【Python, 自然言語処理】

CaboChaで構文解析

構文解析。それは自然言語処理における第2の刺客。

  • 形態素解析 ← 第1の刺客
  • 構文解析 ← 第2の刺客
  • 意味解析 ← ボス
  • 文脈解析 ← ラスボス

Pythonには自然言語処理における構文解析を行ってくれるライブラリCaboChaがありますが、今回はこれを使います。
CaboChaを使って夏目漱石の「こころ」の文章を解析し、「私の」や「僕の」にかかっている名詞を表示します。

今回は具体的には↓のコンテンツになります。

  • 構文解析とは?
  • スクリプトの概要
  • スクリプトのコード全文
  • スクリプトの解説

構文解析とは?

日本語の文章を解析するプログラミングのジャンルに「自然言語処理」というのがあります。
自然言語処理は↓の工程を経て行います。

  • 形態素解析
  • 構文解析
  • 意味解析
  • 文脈解析

このうち最初に行うのが「形態素解析」です。
これは日本語の文章を単語のリストに分解する処理です。
後続の工程における下ごしらえみたいな重要な処理です。

形態素解析を行ったら「構文解析」という処理を行うことができます。
これは単語の「係り受け(かかりうけ)」を解析する処理です。
係り受けとはどの単語がどの単語にかかっているかという単語間の関係です。

これを解析することで例えば「この主語はこの動詞にかかっているな~」とか「この代名詞はこの名詞にかかっているんだな~」といったことがわかります。
単語間の係り受けを解析すればそういった単語の関連付けがわかるので、より具体的な自然言語処理が可能になるわけですね。

CaboChaはこの構文解析を行うライブラリです。
今回はこれを使います。

スクリプトの概要

今回のスクリプトは日本語の文章をCaboChaで解析して、「私の」とか「僕の」という代名詞にかかっている名詞を取得し、それらの関係を出力するものです。
たとえば夏目漱石の「こころ」をこのスクリプトで解析すると↓のような出力になります。

私の -> 内面  
私の -> 心  
私の -> 活動  
私の -> ため  
私の -> 宿命  
私の -> 気分  
私の -> 後ろ  
私の -> 胸  
私の -> 答え  

スクリプトは青空文庫のコンテンツを取得し、解析を行います。
夏目漱石の書籍は著作権切れになっていますので、自由に解析できます。

スクリプトのコード全文

↓がスクリプトの全文です。

"""  
「私の」にかかっている名詞を出力する  

ライセンス: MIT  
作成日: 2020/10/15  
"""  
import CaboCha  
from bs4 import BeautifulSoup  
from urllib.request import urlopen  


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 is_noun(tok):  
    """  
    トークンが名詞かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '名詞' not in pos:  
        return False  

    return True  


def is_pronoun(tok):  
    """  
    トークンが代名詞(私,僕)かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '代名詞' not in pos:  
        return False  

    return tok.surface in ['私', '僕']  


def is_post(tok):  
    """  
    トークンが助詞(の)かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '助詞' not in pos:  
        return False  

    return tok.surface in ['の']  


def read_contents(url):  
    """  
    urlのWebページを読み込みタグを除去する(青空文庫のみに対応)  
    """  
    with urlopen(url) as fin:  
        contents = fin.read().decode('cp932')  

    soup = BeautifulSoup(contents, 'html.parser')  
    return soup.find('div', class_='main_text').get_text()  


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 analyze_token(tree, chunks, from_tok):  
    """  
    トークンを解析して構文木に関連付けられているトークン列を得る  
    """  
    if not from_tok.chunk:  
        return None, None  
    if from_tok.chunk.link < 0:  
        return None, None  

    from_toks = get_toks_by_chunk(tree, from_tok.chunk)  

    to_chunk = chunks[from_tok.chunk.link]  
    to_toks = get_toks_by_chunk(tree, to_chunk)  

    return from_toks, to_toks  


def merge_surface(toks):  
    """  
    トークンの表層形をマージする  
    """  
    s = ''  
    for tok in toks:  
        s += tok.surface  
    return s  


def show(from_toks, to_toks):  
    """  
    トークン列を出力する  
    """  
    from_surface = merge_surface(from_toks)  
    to_surface = merge_surface(to_toks[:1])  
    print(from_surface, '->', to_surface)  


def is_support_to_toks(toks):  
    """  
    トークン列が対象トークンなら(「私の」だったら)  
    """  
    if len(toks) < 1:  
        return False  

    if not is_noun(toks[0]):  
        return False  

    return True  


def is_support_from_toks(toks):  
    """  
    トークン列が対象トークンなら(名詞だったら)  
    """  
    if len(toks) < 2:  
        return False  

    if not is_pronoun(toks[0]):  
        return False  

    if not is_post(toks[1]):  
        return False  

    return True  


def analyze_tree(tree):  
    """  
    構文木を解析する  
    """  
    # チャンクの辞書を生成  
    chunks = gen_chunks(tree)  

    for i in range(tree.size()):  
        tok = tree.token(i)  # トークン読み込み  

        # トークンを解析して関連付けられているトークン列を得る  
        from_toks, to_toks = analyze_token(tree, chunks, tok)  
        if not from_toks:  
            continue  

        # 対象のトークン?  
        if not is_support_from_toks(from_toks):  
            continue  
        if not is_support_to_toks(to_toks):  
            continue  

        # 表示  
        show(from_toks, to_toks)  


def analyze_lines(lines):  
    """  
    行のリストを構文解析する  
    """  
    parser = CaboCha.Parser()  
    for line in lines:  
        tree = parser.parse(line)  
        analyze_tree(tree)  


def main():  
    url = 'ここに青空文庫の作品のURL'  
    contents = read_contents(url)  
    lines = contents.split('\r\n')  
    analyze_lines(lines[1:])  


main()  

スクリプトの解説

スクリプトの全体の流れですが、まず最初に青空文庫のWebページからコンテンツを取得します。
そのコンテンツを行分割し、行ごとに解析を行います。

行の解析にはCaboChaのCaboCha.Parserを使います。
行を解析するとCaboCha.ParserCaboCha.Treeというオブジェクトを生成します。
あとはこのCaboCha.Treeを解析していきます。

最後に解析結果の出力を標準出力に出して完了です。

CaboChaの使用には最初にCaboChaをインポートしておく必要があります。

import CaboCha  

青空文庫のコンテンツの取得

青空文庫のコンテンツの取得にはurllib.reuqest.urlopenBeautifulSoup4を使います。
これらは最初にインポートしておく必要があります。

from bs4 import BeautifulSoup  
from urllib.request import urlopen  

BeautifulSoup4は外部ライブラリなのでpipなどで環境にインストールしておきます。

pip install BeautifulSoup4  

main()関数から処理がはじまりますが、read_contents()関数にurlを渡すとコンテンツが読み込まれます。

def main():  
    url = 'ここに青空文庫の作品のURL'  
    contents = read_contents(url)  
    lines = contents.split('\r\n')  
    analyze_lines(lines[1:])  

read_contents()関数では最初にurlopenurlを開き、オブジェクトからコンテンツを取得します。
このコンテンツはcp932のHTMLドキュメントなので、cp932でデコードしておきます。
デコードしたコンテンツをBeautifulSoup4に渡してsoupにします。
soupからdiv.main_textタグを探して、そのテキストを得ます。

def read_contents(url):  
    """  
    urlのWebページを読み込みタグを除去する(青空文庫のみに対応)  
    """  
    with urlopen(url) as fin:  
        contents = fin.read().decode('cp932')  

    soup = BeautifulSoup(contents, 'html.parser')  
    return soup.find('div', class_='main_text').get_text()  

コンテンツを得たらsplit(\r\n)で改行を分割し、行のリストにします。
あとはこのリストをanalyze_lines()関数に渡します。

行の解析

analyze_lines()関数で行を解析します。
最初にCaboCha.Parserをオブジェクトにしておきます。
行をfor文で回し、この行をCaboCha.Parser.parse()で解析してCaboCha.Treeオブジェクトにします。
あとはこのツリーをanalyze_tree()に渡します。

def analyze_lines(lines):  
    """  
    行のリストを構文解析する  
    """  
    parser = CaboCha.Parser()  
    for line in lines:  
        tree = parser.parse(line)  
        analyze_tree(tree)  

ツリーの解析

analyze_tree()によるツリーの解析では最初にチャンクの辞書(chunks)を生成します。
チャンクとは単語の係り受けを表現するデータです。
これはCaboCha.Chunkオブジェクトになります。

tree.size()でツリー全体のトークン列のサイズを取得できるので、この値だけfor文を回します。
tree.token(i)でトークン(tok)を得ます。

tree, chunks, tokanalyze_tokens()に渡して、tokに関連付けられているトークン列を取得します。
from_toksが「私の」や「僕の」を表すトークン列、to_toksが名詞を含んだトークン列の候補です。

これらのトークン列をis_support_from_toks()is_support_to_toks()で判定し、今回取得する対象のトークン列かどうかチェックします。
チェックが通ったらshow()関数にトークン列を渡して表示します。

def analyze_tree(tree):  
    """  
    構文木を解析する  
    """  
    # チャンクの辞書を生成  
    chunks = gen_chunks(tree)  

    for i in range(tree.size()):  
        tok = tree.token(i)  # トークン読み込み  

        # トークンを解析して関連付けられているトークン列を得る  
        from_toks, to_toks = analyze_token(tree, chunks, tok)  
        if not from_toks:  
            continue  

        # 対象のトークン?  
        if not is_support_from_toks(from_toks):  
            continue  
        if not is_support_to_toks(to_toks):  
            continue  

        # 表示  
        show(from_toks, to_toks)  

チャンクの辞書の生成

gen_chunks()はツリーからチャンクの辞書を生成します。
トークン(CaboCha.Token)はchunkという属性を持っています。
これはそのトークンの持つ他の単語への係り方を表すオブジェクトです。
このchunkNoneでない場合は、そのトークンは別の単語に係っているということになります。
トークンのchunkがあるかどうかチェックして、chunkchunksに保存します。
保存に使うキーは整数になっていることに注意してください。

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  


トークンの解析

analyze_token()関数でトークンに関連付けられているトークン列を取得しています。
トークンのchunklinkという属性を持っていて、これはそのチャンクからどのチャンクに係っているかを示す整数のリンクです。
この整数はchunksのキーになります。

get_toks_by_chunk()関数にチャンクを渡し、そのチャンクの所属するトークン列を得ます。
chunksからfrom_tokのチャンクのリンクを使って、係っている先のチャンクを取得します。
そのチャンクからもget_toks_by_chunk()でトークン列を取得しています。

from_toksが「私の」や「僕の」に相当するトークン列で、to_toksが名詞に相当するトークン列です。
これらのトークン列をreturnします。

def analyze_token(tree, chunks, from_tok):  
    """  
    トークンを解析して構文木に関連付けられているトークン列を得る  
    """  
    if not from_tok.chunk:  
        return None, None  
    if from_tok.chunk.link < 0:  
        return None, None  

    from_toks = get_toks_by_chunk(tree, from_tok.chunk)  

    to_chunk = chunks[from_tok.chunk.link]  
    to_toks = get_toks_by_chunk(tree, to_chunk)  

    return from_toks, to_toks  

get_toks_by_chunk()はチャンクの所属するトークン列を得ます。
チャンク(CaboCha.Chunk)はtoken_posというツリー内におけるチャンクの所属するトークンのポジションを持っています。
このポジションをtoken_sizeの分だけfor文で回して、トークン列を取得します。

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  


品詞の判定

トークンの品詞の判定には↓の関数群を使います。
トークン(CaboCha.Token)は属性featureを持っています。
これはカンマ区切りで並べられた品詞の文字列です。
このfeatureに指定の品詞が含まれてないかチェックします。

それから必要によってトークンのsurface属性もチェックします。
surfaceはトークンの表層形のことで、これは元の文章のそのままの表記を表す文字列です。

def is_noun(tok):  
    """  
    トークンが名詞かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '名詞' not in pos:  
        return False  

    return True  


def is_pronoun(tok):  
    """  
    トークンが代名詞(私,僕)かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '代名詞' not in pos:  
        return False  

    return tok.surface in ['私', '僕']  


def is_post(tok):  
    """  
    トークンが助詞(の)かどうか判定する  
    """  
    pos = tok.feature.split(',')  
    if '助詞' not in pos:  
        return False  

    return tok.surface in ['の']  


トークン列の出力

analyze_tree()関数の最後でshow()関数を呼び出しトークン列を出力します。

def show(from_toks, to_toks):  
    """  
    トークン列を出力する  
    """  
    from_surface = merge_surface(from_toks)  
    to_surface = merge_surface(to_toks[:1])  
    print(from_surface, '->', to_surface)  

merge_surface()関数はトークン列のsurfaceをマージする関数です。

def merge_surface(toks):  
    """  
    トークンの表層形をマージする  
    """  
    s = ''  
    for tok in toks:  
        s += tok.surface  
    return s  

実行結果

このスクリプトを実行すると↓のような結果になります。
(大量に出力されるため↓はその一部です)

私の -> 得意  
私の -> 顔  
私の -> 顔  
私の -> 気分  
私の -> 予期  
私の -> 顔  
私の -> 方  
私の -> 未来  
私の -> すべて  
私の -> 心  
私の -> 良心  
私の -> 良心  
私の -> 自然  
私の -> 顔  
私の -> 顔  
私の -> 恐れ  
私の -> 胸  
私の -> 事  
私の -> 未来  
私の -> 心  
私の -> 胸  
私の -> 自尊心  
私の -> 室  
私の -> 眼  
私の -> 未来  
私の -> 前  
私の -> 名宛  
私の -> 予期  
私の -> 痛切  
私の -> 室  
私の -> 頭  
私の -> 足音  
私の -> 室  
私の -> 後  
私の -> 顔  
私の -> 自然  
私の -> 言葉  
私の -> 後ろ  
私の -> 室  
私の -> 胸  
私の -> 心  

「私の」をメインに係っている名詞が出力されました。
形態素解析だけの処理だと係り受けがわからないため、上のような結果を得ることはむずかしいはずです。
精度が悪くなるんですね。
しかし係り受けを行うと↑のように比較的に良い精度で単語間の関係性を取得できるようです。

おわりに

今回はCaboChaを使ったスクリプトを紹介しました。
ライセンスはMITですので自由に改造など行ってみてください。

🦝 < 構文解析で気分も晴れやか