PythonとCaboChaで構文解析: 誰が天才か判定する【自然言語処理】
- 作成日: 2020-11-11
- 更新日: 2023-12-26
- カテゴリ: 自然言語処理
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.Token
はchunk
という属性を持っていて、これはチャンクのことです。
ツリーからトークン、トークンからチャンクを辿れるわけですね。
トークンがチャンクを持っていたら辞書にチャンクを追加します。
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
が格納されます(おそらく。ドキュメントがないため不明)。
このlink
がchunks
のキーを整数にしていた理由です。
チャンクが見つかったらそのチャンクのトークン列を得ます。
このトークン列からポジティブなワードを持つトークンを探します。
ポジティブなトークンが見つからなかったら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)
でテストを実行します。
このテストはresult
とb
が一致しているかどうかのテストです。
一致してなければテストが失敗し、画面にエラーが出力されます。
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を使って構文解析を行うとこのスクリプトのように比較的に簡単に自然言語を解析することができます。
形態素解析から構文解析にステップアップするにはこういった小さいスクリプトを書くのが良さそうです。
🦝 < 構文解析で天才をあぶり出そう