CaboChaでルールベースの感情分析を行う【自然言語処理, Python】
- 作成日: 2021-02-23
- 更新日: 2023-12-24
- カテゴリ: 自然言語処理
CaboChaで感情分析を行う
私たちが話す言葉は「自然言語」と呼ばれます。
この自然言語をプログラム的に解析することを「自然言語処理」と言います。
自然言語処理は複数の工程に分かれていますが、その中の1つに構文解析という工程があります。
Pythonにはこの構文解析を行えるライブラリにCaboChaがありますが、今回はこのCaboChaを使って感情分析という自然言語処理を行ってみたいと思います。
今回は具体的には↓を見ていきます。
- CaboChaとは?
- 感情分析とは?
- プログラムの実行結果
- プログラムの設計
- プログラムのソースコード
- ソースコードの解析
CaboChaとは?
今回作成するPythonのプログラムで使用する「CaboCha」というライブラリは、構文解析を行うライブラリです。
構文解析とは、自然言語処理の工程の1つです。
自然言語処理は↓のような工程を上から下に向かって進みます。
- 字句解析
- 構文解析
- 意味解析
- 文脈解析
「字句解析」とは文章を単語に分割する解析です。
そして構文解析は、その分割された単語を見て、単語間の係り受けの関係を解析します。
「係り受け(かかりうけ)」とはどの単語がどの単語に係っているかを表すものです。
例えば「犬が歩いた」という文章を見てみます。
これを係り受け解析すると↓のような関係が出力されます。
犬が-D
歩いた
EOS
↑では「犬が」という単語のリストが「歩いた」という単語のリストに係っているのが表現されています。
歩いているわけですが、その歩いているのは誰だ? 犬だ! ということで「犬」という単語に「歩いた」が係っているということですね。
このように構文解析によって単語間の係り受けが解析されると、名詞にかかっている動詞などをプログラム的に取得できるようになります。
係り受けが解析されると、構文解析の次の工程である意味解析に進むことができます。
意味解析では単語間の係り受けを見て、その文章の持つ意味を解析することが可能です。ただ、これをやるのは非常に難しく、あまり文献も出てきません。
感情分析とは?
今回作成するプログラムは入力された文章から感情分析を行い、そのスコアを算出します。
感情分析とは、文章の持つ感情を分析する自然言語処理の分析です。
感情は「ニュートラル」「ネガティブ」「ポジティブ」の3つで評価されます。
- ニュートラル
- ネガティブ
- ポジティブ
たとえば「犬が歩いた」という文章は何の感情も持っていないので、その結果はニュートラルなスコア(つまり0
)です。
いっぽう「犬が泣いた」という文章はネガティブな感情を持っているので、その結果はネガティブなスコア(つまり-1
)です。
そして「犬が笑った」という文章はポジティブな感情を持っているので、その結果はポジティブなスコア(つまり1
)です。
このように感情分析とは文章にスコアを割り振る処理のことを言います。
そのスコアをプログラムで使うようにすると、特定の感情を持つ文章を抽出したりとかいろいろな処理で感情を利用できるようになるとということです。
感情分析の手法は大きく分けて2つあります。
1つが「機械学習による分析」でもう1つが「ルールベースの分析」です。
- 機械学習による分析
- ルールベースの分析
前者の「機械学習による分析」とは、大量の文章データをプログラムに学習させて、スコアを算出しようという手法で、最近の機械学習ブームでポピュラーになってきてる方法です。
しかし学習データが必要な点で、個人でやるにはけっこうめんどくさいというデメリットがあります。
聞くところによるとデータさえ用意出来ればルールベースより楽みたいです。
🦝 < 一長一短やね
そして後者の「ルールベースの分析」が今回行う分析です。
これは辞書を使った地道な解析のことを言います。
たとえば「寒い」とか「悲しい」というワードはネガティブな感情を持っていますが、これらの単語を辞書に登録しておいて、文章の解析時にこの辞書を参照します。
文章にこれらの単語が含まれていたらスコアをネガティブ(-1
)にするという具合です。
辞書を使った解析はコードにするのはかなり簡単ですが、複雑な文章になると途端に難しくなります。
たとえば↓のようなケースです。
- 慣用句への対応
- 否定への対応
- 極性変化単語への対応
- 反語への対応
- 要求表現への対応
- 強化系の副詞への対応
今回は↑のケースにすべて対応するプログラムを作りました。
これらのケースは↓の記事をたいへん参考にさせて頂きました。
プログラムの実行結果
今回作成するプログラムはテストを実装しています。
そのためソースコードをsample.py
で保存し、python -m unittest sample
でプログラムを実行するとテストケースを走らせることができます。
今回は↓のようなテストケースが実装されています。
# フルマッチのテスト
self.eq('君には堪忍袋の緒が切れるよ', -1)
self.eq('この解説は痒いところに手がとどく', 1)
# シングルマッチのテスト
self.eq('このご飯は美味しいね', 1)
self.eq('このご飯は美味しかった', 1)
self.eq('このご飯は美味しそう', 1)
self.eq('このご飯は美味しいかも', 1)
self.eq('このご飯は不味い', -1)
self.eq('このご飯は不味そう', -1)
self.eq('このご飯は不味かった', -1)
# 否定のテスト
# 意味が反転する
self.eq('このご飯は不味くありません', 1)
self.eq('このご飯は不味くないです', 1)
self.eq('このご飯は不味いわけがない', 1)
self.eq('このご飯は美味しくない', -1)
self.eq('このご飯は美味しいわけがない', -1)
self.eq('このご飯は美味しそうに見えない', -1)
self.eq('このご飯は美味しくありません', -1)
# 極性変化単語のテスト
# 「高い」はポジティブに登録されているが、「値段」と組み合わさるとネガティブな意味になる
# 「低い」はネガティブに登録されているが、「腰」と組み合わさるとポジティブな意味になる
self.eq('このご飯は味の割に値段が高い', -1)
self.eq('ひかえおろう。お主は頭が高い', -1)
self.eq('君は腰が低い', 1)
self.eq('勉強のハードルが低い', 1)
# 反語のテスト
self.eq('このご飯は本当に美味しいのでしょうか?', 0)
self.eq('このご飯は本当に不味くないのでしょうか?', 0)
# 要求表現
# 「~てほしい」はネガティブだが、前の単語とからめてスコアを決定
self.eq('味をもっと改良してほしい', -0.5)
self.eq('味をもっと美味しくしてほしい', -0.5)
self.eq('味をもっと不味くしてほしい', -1.5)
# 強化系の副詞
self.eq('このご飯はとても美味しい', 1.5)
self.eq('非常に美味しいご飯だ', 1.5)
self.eq('このご飯はとても不味い', -1.5)
self.eq('このご飯は非常に不味い', -1.5)
self.eq()
は第1引数と第2引数の値を比較し、同じであれば何もしないメソッドです。
↑の場合、たとえば「君には堪忍袋の緒が切れるよ
」という入力に対して「-1
」という結果が返ってきてるのがわかります。
これは入力が感情分析されて-1
というスコアを算出しているという意味になります。
このテストケースを実行すると↓のような結果になります。
$ python -m unittest sample
.
----------------------------------------------------------------------
Ran 1 test in 0.799s
OK
↑の表示はすべてのテストケースが通過したときに表示される結果です。
プログラムの設計
プログラムの設計ですが、今回はCaboChaを使って解析を行います。
つまりデータ構造がCaboChaのデータ構造に依存します。
CaboChaは構文木というツリーを構築します。
そしてこのツリーには単語(トークン)が格納されています。
単語を参照するにはこのツリーを走査して、トークンを順に参照していきます。
単語と単語の係り受けの関係はトークンのchunk
という属性が持っています。
chunk.link
が係り先のチャンク(文節)へのリンクです。
係り先の単語を取得するにはこのchunk.link
を参照します。
このリンクはたとえば「犬が歩いた」という文章では「犬」から「歩いた」という単語を辿れるようになっています。
これは「犬」が「歩いた」という単語に係っているからです。
しかし「歩いた」という単語から「犬」を辿ることは、私の知っている範囲ではできません(もしかしたら出来るかもしれませんが、知ってる人いたら教えてください)。
これは「歩いた」というトークンのchunk.link
がリンク切れになっているためです。
今回は利便性のため「歩いた」という単語からも「犬」を辿れるようにしておきます。
どのように実現するのかと言うとトークンをラップする「ノード」という構造を新しく作ります。
そしてノードには「係り先のノード群」「係り元のノード」「同じ文節のノード群」を参照できる属性を持たせます。
このようなデータ構造をあらかじめ用意しておいて、解析ではこの構造を使うようにします。
🦝 < 合言葉はデータ構造!
🐭 < せやな
プログラムのソースコード
今回作成するプログラムのソースコードは↓になります。
プログラムを実行するには↓のコードをsample.py
などに保存し、python -m unittest sample
を実行します。
そうするとテストケースが実行されます。
"""
ルールベースの感情分析を行うアナライザーのサンプル
License: MIT
Created at: 2021/01/09
"""
import CaboCha
import unittest
class Node:
"""
CaboCha.Tokenのラッパー
係り先のノード、係り元のノード、同じ文節のノードをそれぞれ辿れる構造になっている
"""
def __init__(self, token: CaboCha.Token):
self.token: CaboCha.Token = token # トークン
self.pos: list = token.feature.split(',') # Part Of Speech
self.base_form: str = self.pos[6] if len(self.pos) >= 6 else None # 原形
self.to_nodes: list = [] # 係り先のノード
self.from_node: Node = None # 係り元のノード
self.sibling_nodes: list = [] # 同じ文節のノード
class NodesIter:
"""
Nodeのリストのイテレーター
"""
def __init__(self, nodes: list):
self.nodes: list = nodes
self.index: int = 0
def is_end(self) -> bool:
"""
indexが範囲外ならTrue
"""
return self.index < 0 or self.index >= len(self.nodes)
def next(self) -> None:
"""
indexを1つ進める
"""
self.index += 1
def cur(self, i: int = 0) -> Node:
"""
現在のindex + iからノードを取得する
indexが範囲外ならIndexError
"""
index = self.index + i
if index < 0 or index >= len(self.nodes):
raise IndexError('out of range')
return self.nodes[index]
class Analyzer:
def __init__(self):
# フルマッチに使う単語リスト
# 解析では最初にこのリストへのマッチングを行う
# 慣用句への対応
self.negative_full_words: list = [
['堪忍袋', 'の', '緒', 'が', '切れる'],
# [TODO, TODO],
]
self.positive_full_words: list = [
['痒い', 'ところ', 'に', '手', 'が', 'とどく'],
# [TODO, TODO],
]
# シングルマッチに使う単語リスト
# フルマッチの次にこのリストでマッチングを行う
self.negative_single_words: list = [
'不味い',
'低い',
]
self.positive_single_words: list = [
'美味しい',
'高い',
'改良',
]
# 極性変化ルール
# 組み合わせるとネガ/ポジが反転する単語のルール
self.polarity_change_roule: dict = {
'高い': ['頭', '値段'], # 「高い」と組み合わせるとネガティブな意味になる単語リスト
'低い': ['腰', 'ハードル'], # 「低い」と組み合わせるとポジティブな意味になる単語リスト
}
# 付けると意味が強化される副詞
self.power_adverbs: list = [
['とても'],
['非常', 'に'],
]
def create_chunks(self, tree: CaboCha.Tree) -> dict:
"""
チャンクの辞書を生成
"""
chunks = {}
key = 0
for i in range(tree.size()):
tok = tree.token(i)
if tok.chunk:
chunks[key] = tok.chunk
key += 1
return chunks
def get_nodes_by_chunk(self, nodes: list, chunk: CaboCha.Chunk) -> list:
"""
チャンクに所属するノードを集める
"""
beg: int = chunk.token_pos
end: int = chunk.token_pos + chunk.token_size
dst_nodes: list = []
for i in range(beg, end):
node = nodes[i]
dst_nodes.append(node)
return dst_nodes
def create_nodes(self, tree: CaboCha.Tree) -> list:
"""
ツリーからノードのリストを作成
"""
# ツリーのトークン列をノードのリストに変換する
nodes = []
for i in range(tree.size()):
tok = tree.token(i)
node = Node(tok)
nodes.append(node)
# チャンクの辞書を生成
chunks = self.create_chunks(tree)
for node in nodes:
# 係り先と係り元のノードを設定する
if node.token.chunk and node.token.chunk.link >= 0:
chunk = chunks[node.token.chunk.link]
node.to_nodes = self.get_nodes_by_chunk(nodes, chunk)
for n in node.to_nodes:
n.from_node = node
# 同じ文節のノードを設定する
if node.token.chunk:
sibling_nodes = self.get_nodes_by_chunk(nodes, node.token.chunk)
for n in sibling_nodes:
if id(n) == id(node):
continue
node.sibling_nodes.append(n)
return nodes
def full_match_rows(self, rows: list, niter: NodesIter) -> bool:
"""
niterがrowsにフルマッチするか調べる
フルマッチしていたらTrue, していなかったらFalseを返す
"""
for row in rows:
is_match = True
for i in range(len(row)):
try:
n = niter.cur(i)
except IndexError:
is_match = False
break
w = row[i]
if n.token.surface != w:
is_match = False
break
if is_match:
return True
return False
def full_match(self, niter: NodesIter) -> int:
"""
niterがフルマッチするか調べる
フルマッチしていたらTrue, していなかったらFalseを返す
"""
if self.full_match_rows(self.negative_full_words, niter):
return -1
if self.full_match_rows(self.positive_full_words, niter):
return 1
return 0
def single_match_rows(self, rows: list, node: Node) -> Node:
"""
nodeがrowsにシングルマッチしていたらマッチしたノードを, していなかったらNoneを返す
"""
for w in rows:
if node.base_form == w:
return node
return None
def has_nai(self, node: Node) -> bool:
"""
nodeに「ない」が含まれていたらTrue, 含まれていなかったらFalseを返す
to_nodesを再帰的に参照する
"""
nodes = [node]
nodes.extend(node.sibling_nodes)
for n in nodes:
if n.token.surface == 'ない':
return True
if len(node.to_nodes):
return self.has_nai(node.to_nodes[0]) # ないを再帰的に検索
def is_polarity_change(self, node: Node) -> bool:
"""
nodeが極性変化するならTrue, しないならFalseを返す
"""
if node.token.surface not in self.polarity_change_roule.keys():
return False # ルール適用外
if node.from_node is None:
return False # 係り元の単語がない
roule = self.polarity_change_roule[node.token.surface]
for word in roule:
if word == node.from_node.token.surface:
return True
return False
def is_hango(self, node: Node) -> bool:
"""
nodeに反語が含まれていたらTrue, 含まれていなかったらFalseを返す
"""
i = 0
while i < len(node.sibling_nodes) - 2:
n1 = node.sibling_nodes[i]
n2 = node.sibling_nodes[i + 1]
n3 = node.sibling_nodes[i + 2]
if n1.token.surface == 'でしょ' and \
n2.token.surface == 'う' and \
n3.token.surface == 'か':
return True
i += 1
return False
def is_please(self, node: Node) -> bool:
"""
nodeに要求表現が含まれていたらTrue, 含まれていなかったらFalseを返す
"""
i = 0
while i < len(node.sibling_nodes) - 1:
n1 = node.sibling_nodes[i]
n2 = node.sibling_nodes[i + 1]
if n1.token.surface == 'て' and \
n2.token.surface == 'ほしい':
return True
i += 1
return False
def has_power_adverb(self, node: Node) -> bool:
"""
nodeに意味を強化する副詞が付いていたらTrue, 付いていなかったらFalseを返す
"""
if not node.from_node:
return False
nodes = [node.from_node]
nodes.extend(node.from_node.sibling_nodes)
for row in self.power_adverbs:
if len(row) < len(nodes):
continue
i = 0
is_match = True
while i < len(row):
n = nodes[i]
word = row[i]
if n.token.surface != word:
is_match = False
break
i += 1
if is_match:
return True
return False
def single_match(self, node: Node) -> int:
"""
nodeをシングルマッチさせスコアを返す
"""
match_node = self.single_match_rows(self.negative_single_words, node)
if match_node:
if self.is_hango(match_node): # 反語
return 0
if self.has_nai(match_node): # 否定
return 1
if self.is_polarity_change(match_node): # 極性変化
return 1
if self.is_please(match_node): # 要求
return -1.5
if self.has_power_adverb(match_node): # 意味を強化する副詞がある?
return -1 * 1.5
return -1
match_node = self.single_match_rows(self.positive_single_words, node)
if match_node:
if self.is_hango(match_node): # 反語
return 0
if self.has_nai(match_node): # 否定
return -1
if self.is_polarity_change(match_node): # 極性変化
return -1
if self.is_please(match_node): # 要求
return -0.5
if self.has_power_adverb(match_node): # 意味を強化する副詞がある?
return 1 * 1.5
return 1
return 0
def analyze_nodes(self, nodes: list) -> int:
"""
nodesを解析してスコアを返す
"""
niter = NodesIter(nodes)
total_score = 0
while not niter.is_end():
score = self.full_match(niter)
if score == 0:
node = niter.cur()
score = self.single_match(node)
total_score += score
niter.next()
return total_score
def analyze(self, s: str) -> int:
"""
文字列を解析してスコアを返す
"""
s = s.replace('ありません', 'ないです') # 「ありません」を置換しておく
cp: CaboCha.Parser = CaboCha.Parser()
tree = cp.parse(s)
nodes = self.create_nodes(tree)
return self.analyze_nodes(nodes)
class Test(unittest.TestCase):
def eq(self, a, b):
an = Analyzer()
c = an.analyze(a)
self.assertEqual(c, b)
def test_analyze(self):
# フルマッチのテスト
self.eq('君には堪忍袋の緒が切れるよ', -1)
self.eq('この解説は痒いところに手がとどく', 1)
# シングルマッチのテスト
self.eq('このご飯は美味しいね', 1)
self.eq('このご飯は美味しかった', 1)
self.eq('このご飯は美味しそう', 1)
self.eq('このご飯は美味しいかも', 1)
self.eq('このご飯は不味い', -1)
self.eq('このご飯は不味そう', -1)
self.eq('このご飯は不味かった', -1)
# 否定のテスト
# 意味が反転する
self.eq('このご飯は不味くありません', 1)
self.eq('このご飯は不味くないです', 1)
self.eq('このご飯は不味いわけがない', 1)
self.eq('このご飯は美味しくない', -1)
self.eq('このご飯は美味しいわけがない', -1)
self.eq('このご飯は美味しそうに見えない', -1)
self.eq('このご飯は美味しくありません', -1)
# 極性変化単語のテスト
# 「高い」はポジティブに登録されているが、「値段」と組み合わさるとネガティブな意味になる
# 「低い」はネガティブに登録されているが、「腰」と組み合わさるとポジティブな意味になる
self.eq('このご飯は味の割に値段が高い', -1)
self.eq('ひかえおろう。お主は頭が高い', -1)
self.eq('君は腰が低い', 1)
self.eq('勉強のハードルが低い', 1)
# 反語のテスト
self.eq('このご飯は本当に美味しいのでしょうか?', 0)
self.eq('このご飯は本当に不味くないのでしょうか?', 0)
# 要求表現
# 「~てほしい」はネガティブだが、前の単語とからめてスコアを決定
self.eq('味をもっと改良してほしい', -0.5)
self.eq('味をもっと美味しくしてほしい', -0.5)
self.eq('味をもっと不味くしてほしい', -1.5)
# 強化系の副詞
self.eq('このご飯はとても美味しい', 1.5)
self.eq('非常に美味しいご飯だ', 1.5)
self.eq('このご飯はとても不味い', -1.5)
self.eq('このご飯は非常に不味い', -1.5)
ソースコードの解析
簡単ですがソースコードの解説になります。
必要モジュールのインポート
CaboCha
とunittest
をそれぞれインポートしておきます。
CaboCha
は構文解析、unittest
は単体テストで使います。
import CaboCha
import unittest
Nodeでトークンをラップする
Node
というクラスを作っておきます。
これはCaboChaのトークンのラッパーです。
token
はCaboChaのトークンです。
pos
はtoken
のfeature
属性を分割したものです。
feature
属性は品詞のリストで、カンマ区切りの品詞が並べられている文字列です。
base_form
はトークンの原形を表します。これはpos
の6
番目の要素です。
to_nodes
はトークンから見て係り先のノードのリストです。
from_node
はトークンから見て係り元のノードです。
sibling_nodes
は同じ文節のノードのリストです(自分を除く)。
class Node:
"""
CaboCha.Tokenのラッパー
係り先のノード、係り元のノード、同じ文節のノードをそれぞれ辿れる構造になっている
"""
def __init__(self, token: CaboCha.Token):
self.token: CaboCha.Token = token # トークン
self.pos: list = token.feature.split(',') # Part Of Speech
self.base_form: str = self.pos[6] if len(self.pos) >= 6 else None # 原形
self.to_nodes: list = [] # 係り先のノード
self.from_node: Node = None # 係り元のノード
self.sibling_nodes: list = [] # 同じ文節のノード
NodesIter
プログラムではノードのリストをループ文で参照していくわけですが、添え字を丸出しにしていると何かと不便なので、NodesIter
というクラスを作ってループ処理を抽象化します。
これは内容的にはindex
を進めたり、その添え字のノードを取得したりといった処理です。
class NodesIter:
"""
Nodeのリストのイテレーター
"""
def __init__(self, nodes: list):
self.nodes: list = nodes
self.index: int = 0
def is_end(self) -> bool:
"""
indexが範囲外ならTrue
"""
return self.index < 0 or self.index >= len(self.nodes)
def next(self) -> None:
"""
indexを1つ進める
"""
self.index += 1
def cur(self, i: int = 0) -> Node:
"""
現在のindex + iからノードを取得する
indexが範囲外ならIndexError
"""
index = self.index + i
if index < 0 or index >= len(self.nodes):
raise IndexError('out of range')
return self.nodes[index]
感情分析を行うAnalyzerクラス
今回のプログラムでじっさいに感情分析を行うのはAnalyzer
というクラスです。
Analyzer
のコンストラクタでは必要となる辞書を初期化しておきます。
今回は辞書には最低限のものしか保存していませんが、これを辞書ファイルなどから読み込むようにすれば辞書のパワーがそのまま解析処理の品質になると思います。
class Analyzer:
def __init__(self):
# フルマッチに使う単語リスト
# 解析では最初にこのリストへのマッチングを行う
# 慣用句への対応
self.negative_full_words: list = [
['堪忍袋', 'の', '緒', 'が', '切れる'],
# [TODO, TODO],
]
self.positive_full_words: list = [
['痒い', 'ところ', 'に', '手', 'が', 'とどく'],
# [TODO, TODO],
]
# シングルマッチに使う単語リスト
# フルマッチの次にこのリストでマッチングを行う
self.negative_single_words: list = [
'不味い',
'低い',
]
self.positive_single_words: list = [
'美味しい',
'高い',
'改良',
]
# 極性変化ルール
# 組み合わせるとネガ/ポジが反転する単語のルール
self.polarity_change_roule: dict = {
'高い': ['頭', '値段'], # 「高い」と組み合わせるとネガティブな意味になる単語リスト
'低い': ['腰', 'ハードル'], # 「低い」と組み合わせるとポジティブな意味になる単語リスト
}
# 付けると意味が強化される副詞
self.power_adverbs: list = [
['とても'],
['非常', 'に'],
]
analyze()で感情分析を行う
analyze()
メソッドが解析の入り口です。
これの引数に感情分析させたい文章を渡します。
def analyze(self, s: str) -> int:
"""
文字列を解析してスコアを返す
"""
s = s.replace('ありません', 'ないです') # 「ありません」を置換しておく
cp: CaboCha.Parser = CaboCha.Parser()
tree = cp.parse(s)
nodes = self.create_nodes(tree)
return self.analyze_nodes(nodes)
create_nodes()でツリーからノード群を作成
今回はCaboChaのトークンをNode
クラスでラップして使いますが、create_nodes()
メソッドはCaboChaのツリーからトークン列を読み込んでそれらをノード列に変換するメソッドです。
最初に単純にトークン列をノード列(nodes
)に変換します。
そのあと、そのノード列の係り先ノードや係り元ノード、同じ文節のノード(兄弟ノード)をノードに構築します。
係り先のチャンクの参照にはchunks
という辞書を使います。これはトークンのchunk
が保存された辞書です。
chunk.link
は整数ですが、この整数をキーにしてchunks
を参照することで係り先のチャンクを取り出せるようになっています。
係り先と係り元の構築にはノードのトークンのチャンクを参照し、そのチャンクからノード列を取得し、to_nodes
などにセットします。
ノード列の取得にはget_nodes_by_chunk()
というメソッドを使います。
同じ文節のノード列も同様です。
def create_nodes(self, tree: CaboCha.Tree) -> list:
"""
ツリーからノードのリストを作成
"""
# ツリーのトークン列をノードのリストに変換する
nodes = []
for i in range(tree.size()):
tok = tree.token(i)
node = Node(tok)
nodes.append(node)
# チャンクの辞書を生成
chunks = self.create_chunks(tree)
for node in nodes:
# 係り先と係り元のノードを設定する
if node.token.chunk and node.token.chunk.link >= 0:
chunk = chunks[node.token.chunk.link]
node.to_nodes = self.get_nodes_by_chunk(nodes, chunk)
for n in node.to_nodes:
n.from_node = node
# 同じ文節のノードを設定する
if node.token.chunk:
sibling_nodes = self.get_nodes_by_chunk(nodes, node.token.chunk)
for n in sibling_nodes:
if id(n) == id(node):
continue
node.sibling_nodes.append(n)
return nodes
create_chunks()でチャンクの辞書を生成
create_chunks()
は引数のツリーからチャンクの辞書を生成します。
辞書のキーは整数です。これはchunk.link
で参照できるようにするためです。
def create_chunks(self, tree: CaboCha.Tree) -> dict:
"""
チャンクの辞書を生成
"""
chunks = {}
key = 0
for i in range(tree.size()):
tok = tree.token(i)
if tok.chunk:
chunks[key] = tok.chunk
key += 1
return chunks
🦝 < Pythonの辞書って整数のキーもいけるんだね
🐭 < そうだね
get_nodes_by_chunk()でチャンクに所属するノード列を取得する
get_nodes_by_chunk()
は引数chunk
に所属しているノード列を引数nodes
から抽出するメソッドです。
チャンクはtoken_pos
とtoken_size
という属性を持っていますが、これを参照することでチャンクに所属しているトークン列のインデックスを得ることができます。
トークン列のインデックスとノード列(nodes
)のインデックスはイコールになっているので、nodes
をインデックスで参照すればチャンクに所属しているノードを得ることができます。
nodes
の構造がトークン列と異なっている場合は奇怪なバグになると思うので注意してください。
def get_nodes_by_chunk(self, nodes: list, chunk: CaboCha.Chunk) -> list:
"""
チャンクに所属するノードを集める
"""
beg: int = chunk.token_pos
end: int = chunk.token_pos + chunk.token_size
dst_nodes: list = []
for i in range(beg, end):
node = nodes[i]
dst_nodes.append(node)
return dst_nodes
analyze_nodes()でノード列を分析する
analyze_nodes()
はノード列を感情分析するメソッドです。
内容的にはノード列をwhile
文で回して、full_match()
とsingle_match()
にかけます。
そしてそのスコアをtotal_score
に蓄積して、return
します。
ノード列の参照にはNodesIter
を使っています。
def analyze_nodes(self, nodes: list) -> int:
"""
nodesを解析してスコアを返す
"""
niter = NodesIter(nodes)
total_score = 0
while not niter.is_end():
score = self.full_match(niter)
if score == 0:
node = niter.cur()
score = self.single_match(node)
total_score += score
niter.next()
return total_score
full_match()で慣用句を分析する
full_match()
は現在のノードが慣用句の辞書にマッチするか調べるメソッドです。
内部的にはfull_match_rows()
に処理を委譲しています。
negative_full_words
を使っている場合、マッチしたら-1
を返します。
positive_single_words
を使っている場合、マッチしたら1
を返します。
いずれにもマッチしなかったらニュートラルなスコアである0
を返します。
def full_match(self, niter: NodesIter) -> int:
"""
niterがフルマッチするか調べる
フルマッチしていたらTrue, していなかったらFalseを返す
"""
if self.full_match_rows(self.negative_full_words, niter):
return -1
if self.full_match_rows(self.positive_full_words, niter):
return 1
return 0
full_match_rows()で慣用句にマッチするか調べる
full_match_rows()
は引数のrows
(慣用句の辞書)が現在のノードにマッチするか調べ、マッチしていたらTrue
を、マッチしていなかったらFalse
を返します。
rows
からrow
を一行ずつ取り出してますが、このrow
が1慣用句になります。row
の中には慣用句の単語が入っています。
このrow
をfor
文で回し、その添え字をniter.cur()
に渡してノードを取得し、比較します。
niter.cur()
に添え字を渡すと現在のインデックスにその添え字を足し算したインデックスのノードを返します。
インデックスが範囲外であればIndexError
が飛んできます。
また比較にはノードのトークンのsurface
属性を使っています。
surface
とは表層形のことで、これは文章のそのままの表記の文字列のことを指します。
row
を回して、すべての単語がノード列とマッチしてたらis_match
フラグがTrue
になり、マッチしていなかったらis_match
フラグがFalse
になります。
is_match
フラグがTrue
であればノード列が辞書の慣用句にヒットしたことになるので、True
を返します。
def full_match_rows(self, rows: list, niter: NodesIter) -> bool:
"""
niterがrowsにフルマッチするか調べる
フルマッチしていたらTrue, していなかったらFalseを返す
"""
for row in rows:
is_match = True
for i in range(len(row)):
try:
n = niter.cur(i)
except IndexError:
is_match = False
break
w = row[i]
if n.token.surface != w:
is_match = False
break
if is_match:
return True
return False
single_match()でノードが辞書にマッチするか調べる
single_match()
メソッドは引数node
が辞書にマッチするか調べるメソッドです。
内容的にはsingle_match_rows()
に処理を委譲しています。
single_match_rows()
はノードが辞書にマッチしていたらマッチしたノードを返します。
single_match()
はnegative_single_words
にマッチしていたら負数のスコアを返すルーチンに入ります。
そしてpositive_single_words
にマッチしていたら正数のスコアを返すルーチンに入ります。
いずれにもマッチしていなかったらニュートラルなスコアを返します。
マッチしていたらis_hango()
やhas_nai()
などを使って反語や否定などの評価をします。
反語であればスコアをニュートラルにし、否定であればスコアを判定させるという具合です。
def single_match(self, node: Node) -> int:
"""
nodeをシングルマッチさせスコアを返す
"""
match_node = self.single_match_rows(self.negative_single_words, node)
if match_node:
if self.is_hango(match_node): # 反語
return 0
if self.has_nai(match_node): # 否定
return 1
if self.is_polarity_change(match_node): # 極性変化
return 1
if self.is_please(match_node): # 要求
return -1.5
if self.has_power_adverb(match_node): # 意味を強化する副詞がある?
return -1 * 1.5
return -1
match_node = self.single_match_rows(self.positive_single_words, node)
if match_node:
if self.is_hango(match_node): # 反語
return 0
if self.has_nai(match_node): # 否定
return -1
if self.is_polarity_change(match_node): # 極性変化
return -1
if self.is_please(match_node): # 要求
return -0.5
if self.has_power_adverb(match_node): # 意味を強化する副詞がある?
return 1 * 1.5
return 1
return 0
single_match_rows()で辞書にノードがマッチするか調べる
single_match_rows()
は引数rows
(辞書)に引数node
がマッチするか調べ、マッチしていたらnode
を返し、マッチしていなかったらNone
を返します。
マッチのさいの比較では辞書の単語とnode
のbase_form
を比較します。
base_form
は原形のことで、たとえば「歩いた」という単語の原形は「歩く」です。
base_form
自体は構文解析の前の字句解析(形態素解析)で生成されます。
その結果はCaboChaのトークンが持っているので、node.base_form
はそれを流用している形になります。
def single_match_rows(self, rows: list, node: Node) -> Node:
"""
nodeがrowsにシングルマッチしていたらマッチしたノードを, していなかったらNoneを返す
"""
for w in rows:
if node.base_form == w:
return node
return None
is_hango()でノードが反語かどうか調べる
反語とは「~でしょうか」が含まれている文です。
これは例えば「良いのでしょうか?」というふうにポジティブな単語をニュートラルにする効果があります。
反語を考慮しない場合は「良い」がポジティブなので、文章のスコアは+1
になりますが、反語を考慮した場合はこのスコアが0
になります。
ノードの同じ文節のノードを参照し、「でしょ」「う」「か」が並んでいたらTrue
を返します。
並んでいなかったらFalse
を返します。
def is_hango(self, node: Node) -> bool:
"""
nodeに反語が含まれていたらTrue, 含まれていなかったらFalseを返す
"""
i = 0
while i < len(node.sibling_nodes) - 2:
n1 = node.sibling_nodes[i]
n2 = node.sibling_nodes[i + 1]
n3 = node.sibling_nodes[i + 2]
if n1.token.surface == 'でしょ' and \
n2.token.surface == 'う' and \
n3.token.surface == 'か':
return True
i += 1
return False
has_nai()で「ない」があるか調べる
has_nai()
メソッドはノードに「ない」が含まれているかどうか調べ、含まれていたらTrue
を返し含まれていなかったらFalse
を返します。
has_nai()
は引数node
の同じ文節のノードを参照し、そのsurface
を調べます。
surface
が「ない」だったらTrue
を返します。
このメソッドはto_nodes
を再帰的に参照します。
つまり、係り先のノードが存在する場合、その係り先のノードも含めて調べていきます。
def has_nai(self, node: Node) -> bool:
"""
nodeに「ない」が含まれていたらTrue, 含まれていなかったらFalseを返す
to_nodesを再帰的に参照する
"""
nodes = [node]
nodes.extend(node.sibling_nodes)
for n in nodes:
if n.token.surface == 'ない':
return True
if len(node.to_nodes):
return self.has_nai(node.to_nodes[0]) # ないを再帰的に検索
is_polarity_change()で極性変化するか調べる
is_polarity_change()
は引数node
の単語が極性変化するか調べるメソッドです。
極性変化とは特定の単語との組み合わせでスコアが反転する単語のことです。
このメソッドはコンストラクタの↓の辞書を参照します。
# 極性変化ルール
# 組み合わせるとネガ/ポジが反転する単語のルール
self.polarity_change_roule: dict = {
'高い': ['頭', '値段'], # 「高い」と組み合わせるとネガティブな意味になる単語リスト
'低い': ['腰', 'ハードル'], # 「低い」と組み合わせるとポジティブな意味になる単語リスト
}
たとえば「高い」という単語はポジティブですが、「頭が高い」だとネガティブな意味になります。「値段が高い」でも同様です。
また「低い」という単語はネガティブですが、「腰が低い」だとポジティブな意味になります。
def is_polarity_change(self, node: Node) -> bool:
"""
nodeが極性変化するならTrue, しないならFalseを返す
"""
if node.token.surface not in self.polarity_change_roule.keys():
return False # ルール適用外
if node.from_node is None:
return False # 係り元の単語がない
roule = self.polarity_change_roule[node.token.surface]
for word in roule:
if word == node.from_node.token.surface:
return True
return False
is_please()で要求表現を調べる
is_please()
メソッドは引数node
に要求表現が含まれているかどうか調べるメソッドです。
内容的にはnode
の同じ文節のノードを参照し、「て」「ほしい」が含まれていないか調べます。
「~してほしい」という要求はネガティブな意味になりやすいです。
そのためsingle_match()
メソッドではis_please()
にマッチする場合はネガティブなスコアを振っています。
def is_please(self, node: Node) -> bool:
"""
nodeに要求表現が含まれていたらTrue, 含まれていなかったらFalseを返す
"""
i = 0
while i < len(node.sibling_nodes) - 1:
n1 = node.sibling_nodes[i]
n2 = node.sibling_nodes[i + 1]
if n1.token.surface == 'て' and \
n2.token.surface == 'ほしい':
return True
i += 1
return False
has_power_adverb()で強化系の副詞が付いているか調べる
has_power_adverb()
は引数node
に強化系の副詞が付いているか調べます。
🦝 < 強化系だって
🐭 < 念能力者か?
強化系の副詞と言うのは意味を強める副詞のことです。
たとえば「とても」とか「非常に」がそれに当たります。
# 付けると意味が強化される副詞
self.power_adverbs: list = [
['とても'],
['非常', 'に'],
]
このメソッドはnode
の同じ文節のノードを参照し、それに辞書の単語が含まれていたらTrue
を返し、含まれていなかったらFalse
を返します。
def has_power_adverb(self, node: Node) -> bool:
"""
nodeに意味を強化する副詞が付いていたらTrue, 付いていなかったらFalseを返す
"""
if not node.from_node:
return False
nodes = [node.from_node]
nodes.extend(node.from_node.sibling_nodes)
for row in self.power_adverbs:
if len(row) < len(nodes):
continue
i = 0
is_match = True
while i < len(row):
n = nodes[i]
word = row[i]
if n.token.surface != word:
is_match = False
break
i += 1
if is_match:
return True
return False
テストを書く
今回は単体テストでプログラムの動作を担保します。
テスト内容は↓のような内容です。
ソースコードの辞書では期待通りの動作をしていますが、辞書をファイルにして量を増やしたらまたバグとか出るかもしれません。
また今回は「未知語」の判定はしていません。興味ある人はやってみてください。
class Test(unittest.TestCase):
def eq(self, a, b):
an = Analyzer()
c = an.analyze(a)
self.assertEqual(c, b)
def test_analyze(self):
# フルマッチのテスト
self.eq('君には堪忍袋の緒が切れるよ', -1)
self.eq('この解説は痒いところに手がとどく', 1)
# シングルマッチのテスト
self.eq('このご飯は美味しいね', 1)
self.eq('このご飯は美味しかった', 1)
self.eq('このご飯は美味しそう', 1)
self.eq('このご飯は美味しいかも', 1)
self.eq('このご飯は不味い', -1)
self.eq('このご飯は不味そう', -1)
self.eq('このご飯は不味かった', -1)
# 否定のテスト
# 意味が反転する
self.eq('このご飯は不味くありません', 1)
self.eq('このご飯は不味くないです', 1)
self.eq('このご飯は不味いわけがない', 1)
self.eq('このご飯は美味しくない', -1)
self.eq('このご飯は美味しいわけがない', -1)
self.eq('このご飯は美味しそうに見えない', -1)
self.eq('このご飯は美味しくありません', -1)
# 極性変化単語のテスト
# 「高い」はポジティブに登録されているが、「値段」と組み合わさるとネガティブな意味になる
# 「低い」はネガティブに登録されているが、「腰」と組み合わさるとポジティブな意味になる
self.eq('このご飯は味の割に値段が高い', -1)
self.eq('ひかえおろう。お主は頭が高い', -1)
self.eq('君は腰が低い', 1)
self.eq('勉強のハードルが低い', 1)
# 反語のテスト
self.eq('このご飯は本当に美味しいのでしょうか?', 0)
self.eq('このご飯は本当に不味くないのでしょうか?', 0)
# 要求表現
# 「~てほしい」はネガティブだが、前の単語とからめてスコアを決定
self.eq('味をもっと改良してほしい', -0.5)
self.eq('味をもっと美味しくしてほしい', -0.5)
self.eq('味をもっと不味くしてほしい', -1.5)
# 強化系の副詞
self.eq('このご飯はとても美味しい', 1.5)
self.eq('非常に美味しいご飯だ', 1.5)
self.eq('このご飯はとても不味い', -1.5)
self.eq('このご飯は非常に不味い', -1.5)
おわりに
今回はCaboChaでルールベースの感情分析をやってみました。
🦝 < 記事が長い
はい。ソースコードが400行と、ブログに載せるにはつらい行数になってしまいました。
記事自体の行数は1200行超えです。なかなか読み応えがあるのではないでしょうか。
🐭 < 読む人おるんかいな
🦊 < これぐらいならいるよ
ではシーユーアゲイン、カウボーイ。