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.Parser
はCaboCha.Tree
というオブジェクトを生成します。
あとはこのCaboCha.Tree
を解析していきます。
最後に解析結果の出力を標準出力に出して完了です。
CaboChaの使用には最初にCaboCha
をインポートしておく必要があります。
import CaboCha
青空文庫のコンテンツの取得
青空文庫のコンテンツの取得にはurllib.reuqest.urlopen
とBeautifulSoup4
を使います。
これらは最初にインポートしておく必要があります。
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()
関数では最初にurlopen
でurl
を開き、オブジェクトからコンテンツを取得します。
このコンテンツは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
, tok
をanalyze_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
という属性を持っています。
これはそのトークンの持つ他の単語への係り方を表すオブジェクトです。
このchunk
がNone
でない場合は、そのトークンは別の単語に係っているということになります。
トークンのchunk
があるかどうかチェックして、chunk
をchunks
に保存します。
保存に使うキーは整数になっていることに注意してください。
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()
関数でトークンに関連付けられているトークン列を取得しています。
トークンのchunk
はlink
という属性を持っていて、これはそのチャンクからどのチャンクに係っているかを示す整数のリンクです。
この整数は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ですので自由に改造など行ってみてください。
(^ _ ^) | 構文解析で気分も晴れやか |