CaboChaでルールベースの感情分析を行う【自然言語処理, Python】

190, 2021-02-23

目次

CaboChaで感情分析を行う

私たちが話す言葉は「自然言語」と呼ばれます。
この自然言語をプログラム的に解析することを「自然言語処理」と言います。

スポンサーリンク

自然言語処理は複数の工程に分かれていますが、その中の1つに構文解析という工程があります。
Pythonにはこの構文解析を行えるライブラリにCaboChaがありますが、今回はこのCaboChaを使って感情分析という自然言語処理を行ってみたいと思います。

今回は具体的には↓を見ていきます。

  • CaboChaとは?
  • 感情分析とは?
  • プログラムの実行結果
  • プログラムの設計
  • プログラムのソースコード
  • ソースコードの解析

CaboChaとは?

今回作成するPythonのプログラムで使用する「CaboCha」というライブラリは、構文解析を行うライブラリです。

構文解析とは、自然言語処理の工程の1つです。
自然言語処理は↓のような工程を上から下に向かって進みます。

  1. 字句解析
  2. 構文解析
  3. 意味解析
  4. 文脈解析

「字句解析」とは文章を単語に分割する解析です。
そして構文解析は、その分割された単語を見て、単語間の係り受けの関係を解析します。
「係り受け(かかりうけ)」とはどの単語がどの単語に係っているかを表すものです。

例えば「犬が歩いた」という文章を見てみます。
これを係り受け解析すると↓のような関係が出力されます。

  犬が-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)

スポンサーリンク

ソースコードの解析

簡単ですがソースコードの解説になります。

必要モジュールのインポート

CaboChaunittestをそれぞれインポートしておきます。
CaboChaは構文解析、unittestは単体テストで使います。

import CaboCha
import unittest

Nodeでトークンをラップする

Nodeというクラスを作っておきます。
これはCaboChaのトークンのラッパーです。

tokenはCaboChaのトークンです。
postokenfeature属性を分割したものです。
feature属性は品詞のリストで、カンマ区切りの品詞が並べられている文字列です。
base_formはトークンの原形を表します。これはpos6番目の要素です。
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_postoken_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の中には慣用句の単語が入っています。
このrowfor文で回し、その添え字を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を返します。
マッチのさいの比較では辞書の単語とnodebase_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行超えです。なかなか読み応えがあるのではないでしょうか。

読む人おるんかいな

これぐらいならいるよ

ではシーユーアゲイン、カウボーイ。


スポンサーリンク

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。