ユーニックス総合研究所

  • home
  • archives
  • janome-emotions

Janomeで辞書を使った感情分析【自然言語処理, Python】

Janomeで感情分析

人間が話す言葉を「自然言語」と言います。
その自然言語をプログラム的に解析する処理を「自然言語処理」と言います。

そして自然言語処理の分野の中で、文章の感情を分析することを「感情分析」と言います。

今回はこの感情分析をPythonの形態素解析ライブラリである「Janome」と、東北大の乾・岡崎研究室が公開されている「日本語評価極性辞書」を使って解析してみたいと思います。
具体的には↓を見ていきます。

  • Janomeとは?
  • 感情分析とは?
  • 日本語評価極性辞書
  • Janomeで感情分析を行う
  • ソースコード
  • ソースコードの解説
  • プログラムの実行

Janomeとは?

今回、感情分析に使うPythonのライブラリはJanomeというライブラリです。

Janomeは形態素解析を行うライブラリです。
形態素解析とは自然言語処理の複数ある工程の内、「字句解析」に相当する解析です。
形態素解析とは早い話、日本語の文章を単語のリストに変換する解析のことを言います。

たとえば「花がきれい」という文章があるとして、この文章を形態素解析して単語のリストに変換すると

花 / が / きれい  

↑のように分割することができます。
形態素解析で文章を単語のリストに変換することで、字句解析以降の解析をやりやすくします。
それはたとえば構文解析、意味解析、文脈解析などです。

感情分析とは?

自然言語処理における感情分析とは、文章が持つ感情を数値に変換する分析のことを言います。
感情は大きく分けて↓の3つに分類されます。

  • ニュートラル
  • ネガティブ
  • ポジティブ

これといった感情をともなっていない文章は「ニュートラル」なスコアになります。
いっぽう、「嫌い」「駄目」といったネガティブな文章はネガティブなスコアに、「好き」「当たり」といったポジティブな文章はポジティブなスコアになります。
ニュートラルなスコアは0, ネガティブなスコアは負数、ポジティブなスコアは正数で表現されます。

文章の持つネガ/ポジな感情を数値化することで、文章を計算的に分類できるようになります。
たとえば会社の製品への感情を、大量のツイートから分析し、消費者がその製品に対してどのような感情を持っているか分析することによって、市場におけるその製品の立ち位置を把握することができます。
その他にも会話AIが、会話相手の感情を把握できるようになって、相手が不機嫌な時は相手を気遣ったりとか処理を分岐できるようになります。

このように感情分析は、感情を持つ生物(人間)に対して有効な分析です。
まぁ人間に限らず、猫や犬も感情を持っていますが、そうすると猫語や犬語の分析が必要になりますね・・・・・・。

日本語評価極性辞書

自然言語処理で感情分析を行う場合、大きく分けて2つの方法があります。
それは↓のような方法です。

  • ルールベースの分析
  • 機械学習による分析

今回使うのは「ルールベースの分析」です。
ルールベースの分析とは、ネガ/ポジなワードを文章から見つけて、それに対してスコアを振っていく方法です。
この時、ネガ/ポジなワードを見つけるにはあらかじめネガ/ポジなワードを分類してある辞書を使います。
文章を解析し、ワードを拾い上げて、辞書を参照してそのワードがネガ/ポジかどうか判断するのがルールベースの分析です。

今回はこのルールベースの分析を行いますが、これには辞書が必要です。
東北大学の乾・鈴木研究室で感情分析のための辞書「日本語評価極性辞書」が公開されているのでこれを使います。

日本語評価極性辞書は2種類あり、1つは「用言」をまとめた辞書で、もう1つは「名詞」をまとめた辞書です。
今回は「用言」をまとめた辞書を使います。

この辞書の内容は↓のようなリストになっています。

ネガ(経験)  あがく  
ネガ(経験)  あきらめる  
...  

ポジ(経験)  あこがれる  
ポジ(経験)  あじわう  
...  

この辞書をPythonでプログラム的に解析して、Pythonのデータ構造に変換します。
そしてそのデータ構造を参照しながら、Janomeで形態素解析した文章を分析していくという寸法です。

Janomeで感情分析を行う

今回作成するプログラムの設計は↓のようになります。

  1. 日本語評価極性辞書を読み込む
  2. Janomeで文章を単語に分割
  3. 単語のリストを辞書にマッチさせスコアを出す

↑の一連の処理はAnalyzerというクラスを作ってそのメソッドに行わせます。
辞書の読み込みはload()というメソッドで行い、スコアを出すのはanalyze()というメソッドで行います。

また今回はプログラムの実行にはPythonの単体テストを使います。
これはunittestが提供する機能で、unittest.TestCaseを継承したクラスを作り、コマンドラインからpython -m unittestを実行することでテストケースを実行することができます。

ソースコード

↓が今回作成したソースコードの全文です。
このプログラムを実行するには↓のコードをsample.pyなどで保存します。
それからpython -m unittest sampleでテストケースを実行します。

"""  
ルールベースの感情分析を行う  

License: MIT  
"""  
import janome  
from janome.tokenizer import Tokenizer  
import unittest  


class TokensIter:  
    """  
    トークン列のイテレーター  
    """  
    def __init__(self, tokens: list):  
        self.tokens: list = tokens  
        self.index: int = 0  

    def is_end(self) -> bool:  
        """  
        インデックスが範囲外だったらTrue  

        @return {bool}  
        """  
        return self.index < 0 or self.index >= len(self.tokens)  

    def next(self) -> None:  
        """  
        インデックスを1つ進める  
        """  
        self.index += 1  

    def cur(self, i: int) -> janome.tokenizer.Token:  
        """  
        現在の状態からトークンを得る  

        @param i 参照位置(indexがオリジン)  
        @return {janome.tokenizer.Token}  
        """  
        index = self.index + i  
        if index < 0 or index >= len(self.tokens):  
            return None  
        return self.tokens[index]  


class Analyzer:  
    """  
    ネガ/ポジなワード解析器  
    analyze()メソッドは入力を解析しスコアを返す  
    """  
    def __init__(self):  
        self.negative_words: list = []  # ネガティブなワード  
        self.positive_words: list = []  # ポジティブなワード  
        self.tokenizer: Tokenizer = Tokenizer()  # トーカナイザー  

    def load(self, fname: str, encoding: str='utf-8') -> None:  
        """  
        ファイルを読み込んでネガティブ/ポジティブなワードに格納する  

        @param fname ファイル名  
        @param encoding エンコーディング  
        """  
        with open(fname, 'rt', encoding=encoding) as fin:  
            self.load_stream(fin)  

    def load_stream(self, fin) -> None:  
        """  
        ストリームをパースしてネガティブ/ポジティブなワードに格納する  

        @param fin ストリーム  
        """  
        self.negative_words = []  
        self.positive_words = []  

        for line in fin.readlines():  
            one, two = line.split('\t')  
            two = two.rstrip().split(' ')  
            if 'ネガ' in one:  
                self.negative_words.append(two)  
            elif 'ポジ' in one:  
                self.positive_words.append(two)  

    def analyze(self, s: str) -> int:  
        """  
        入力文章を解析してスコアを返す  

        @param s 入力文章  
        @return スコア  
        """  
        toks = self.tokenizer.tokenize(s)  
        return self.analyze_tokens(list(toks))  

    def analyze_tokens(self, toks: list) -> int:  
        """  
        構文木を解析してスコアを返す  

        @param toks トークン列  
        @return スコア  
        """  
        i = 0  
        score = 0  
        titer = TokensIter(toks)  

        while not titer.is_end():  
            score += self.match(titer)  
            titer.next()  

        return score  

    def match(self, titer: TokensIter) -> int:  
        """  
        現在のインデックスを基点にしたトークン列が、ネガティブ/ポジティブなワードにマッチするか調べる  

        @param toks トークン列  
        @param index 基点のインデックス  
        @return スコア, 最終的なトークン列のインデックス  
        """  
        is_match = self.match_rows(self.negative_words, titer)  
        if is_match:  
            return -1  

        is_match = self.match_rows(self.positive_words, titer)  
        if is_match:  
            return 1  

        return 0  

    def match_rows(self, rows: list, titer: TokensIter) -> bool:  
        """  
        rowsの中にトークン列が含まれているか調べる  

        @param rows リスト  
        @param toks トークン列  
        @param index 基点のインデックス  
        @return マッチしたかどうかのbool, 最終的なトークン列のインデックス  
        """  
        for row in rows:  
            is_match = True  # マッチしている前提  

            for i in range(len(row)):  
                t = titer.cur(i)  
                if t is None:  
                    is_match = False  # フラグを  
                    break  

                w = row[i]  
                if t.surface != w:  
                    is_match = False  # バッキバキ  
                    break  

            if is_match:  
                titer.index += len(row)  # 読み込んだ分を進める  
                return True  

        return False  


class Test(unittest.TestCase):  
    def eq(self, a, b):  
        c = self.an.analyze(a)  
        self.assertEqual(c, b)  

    def test_analyze(self):  
        self.an = Analyzer()  
        self.an.load('wago.121808.pn')  

        # ニュートラルなテスト  
        self.eq('今日は買い物に行った', 0)  
        self.eq('今日も通常運転だった', 0)  
        self.eq('朝昼晩ご飯を食べた', 0)  

        # ネガティブなテスト  
        self.eq('今日は物寂しい', -1)  
        self.eq('なんていうか君は偏屈だ', -1)  
        self.eq('君の行いは無作法だ', -1)  
        self.eq('野暮ったいことを言って面白みがない', -2)  

        # ポジティブなテスト  
        self.eq('僕の人生は薔薇色だ', 1)  
        self.eq('気持ちが良く朗らかだ', 1)  
        self.eq('教科書を用いることができる', 1)  
        self.eq('涙ぐましい努力で気持ちが和らぐ', 2)  

        """  
        期待しない結果  
        辞書に↓を追加すれば解析可能  
            ポジ(評価)  楽しかっ た  
            ポジ(評価)  嬉しかっ た  
        """  
        self.eq('楽しかったし嬉しかったです', 0)  # 期待する結果は2  

ソースコードの解説

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

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

最初に必要モジュールをインポートしておきます。
Janomeにはいろいろなモジュールがありますが、今回は形態素解析のみを行いたいのでTokenizerというクラスをインポートしておきます。
このクラスを使うと形態素解析を行うことができます。
それから関数のアノテート(返り値の型のメモ)用にjanome本体もインポートしておきます。
あと今回はテストを書きますのでunittestもインポートしておきます。

import janome  
from janome.tokenizer import Tokenizer  
import unittest  

TokensIterでトークン列を抽象化

JanomeのTokenizerは文章を解析するとトークン列を返してくれます。
これはジェネレーターで、list()に渡すとリストに変換することができます。
このまま使っても良いのですが、今回は利便性のためにリストとそのインデックスを抽象化するクラスを作ります。
↓のTokensIterがそれです。
TokensIterは属性にトークン列とそのインデックスを取り、それらを使ったメソッドを提供します。

このクラスを作ることでトークン列の操作、たとえばインデックスを進めたりとかインデックスを復元するとかの操作が簡単に行えるようになります。
今回は使う辞書は用言の辞書ですが、これは「恰好 が つく」などのように単語が分割されています。
Pythonのデータに変換するときも、この単語の分割はそのまま格納します。
つまり、スペースをチョップしてリストに変換して保存します。
それでこの単語のリストに、Janomeで生成したトークン列をマッチさせるわけです。

そのため生のインデックスを使うよりは、このように抽象化したクラスを使ったほうが解析が行いやすくなります。
単純な文字列の比較ではなく、リスト内の複数の要素の比較なのでこのような工夫を行っています。

class TokensIter:  
    """  
    トークン列のイテレーター  
    """  
    def __init__(self, tokens: list):  
        self.tokens: list = tokens  
        self.index: int = 0  

    def is_end(self) -> bool:  
        """  
        インデックスが範囲外だったらTrue  

        @return {bool}  
        """  
        return self.index < 0 or self.index >= len(self.tokens)  

    def next(self) -> None:  
        """  
        インデックスを1つ進める  
        """  
        self.index += 1  

    def cur(self, i: int) -> janome.tokenizer.Token:  
        """  
        現在の状態からトークンを得る  

        @param i 参照位置(indexがオリジン)  
        @return {janome.tokenizer.Token}  
        """  
        index = self.index + i  
        if index < 0 or index >= len(self.tokens):  
            return None  
        return self.tokens[index]  

Analyzer

解析を行うメインのクラスがAnalyzerです。
Analyzerは属性にnegative_wordspositive_wordsというリストを持っています。
これには読み込んだ辞書のネガ/ポジなワードのリストが格納されます。
構造的には多次元リストです。
↓のような感じになります。

self.positive_words = [  
    ['恰好', 'が', 'つく'],  # ←これ1行が用言1つ分  
    ['感無量', 'です'],  
]  

それからtokenizer属性にJanomeのTokenizerをオブジェクトにして持っておきます。

class Analyzer:  
    """  
    ネガ/ポジなワード解析器  
    analyze()メソッドは入力を解析しスコアを返す  
    """  
    def __init__(self):  
        self.negative_words: list = []  # ネガティブなワード  
        self.positive_words: list = []  # ポジティブなワード  
        self.tokenizer: Tokenizer = Tokenizer()  # トーカナイザー  
    ...  

load()で辞書の読み込み

辞書の読み込みはload()メソッドを使います。
loadの引数fnameに辞書のファイル名を渡します。
load()はそのファイルを読み込んで処理をload_stream()に委譲します。

    def load(self, fname: str, encoding: str='utf-8') -> None:  
        """  
        ファイルを読み込んでネガティブ/ポジティブなワードに格納する  

        @param fname ファイル名  
        @param encoding エンコーディング  
        """  
        with open(fname, 'rt', encoding=encoding) as fin:  
            self.load_stream(fin)  

load_stream()でストリームの解析

load_stream()で辞書のファイルオブジェクトをパースしてデータに変換します。
内容的にはファイルオブジェクトから行を読み込み、その行を1行づつ解析します。
辞書の行はTSV(Tab Separated Values)になっているので、タブ(\t)で行を分割します。
そして余計な改行を除いて用言の半角スペースをチョップします。
あとは行に「ネガ」が含まれていたらチョップした用言をnegative_wordsに。
行に「ポジ」が含まれていたらpositive_wordsに保存します。

    def load_stream(self, fin) -> None:  
        """  
        ストリームをパースしてネガティブ/ポジティブなワードに格納する  

        @param fin ストリーム  
        """  
        self.negative_words = []  
        self.positive_words = []  

        for line in fin.readlines():  
            one, two = line.split('\t')  
            two = two.rstrip().split(' ')  
            if 'ネガ' in one:  
                self.negative_words.append(two)  
            elif 'ポジ' in one:  
                self.positive_words.append(two)  

analyze()で文章を解析

analyze()メソッドの引数に文章を渡すと、その文章の感情分析を行いスコアを出すことができます。
スコアは返り値として返ってきます。
内容的にはTokenizertokenize()で文章を形態素解析しトークン列にします。
そしてそのトークン列をanalyze_tokens()に渡して処理を委譲します。

    def analyze(self, s: str) -> int:  
        """  
        入力文章を解析してスコアを返す  

        @param s 入力文章  
        @return スコア  
        """  
        toks = self.tokenizer.tokenize(s)  
        return self.analyze_tokens(list(toks))  

analyze_tokens()でトークン列を解析

analyze_tokens()でトークン列を解析します。
解析した結果のスコアは返り値として返します。
トークン列はこのメソッドの内部でTokensIter(イテレーター)に変換されます。
そしてイテレーターを進めながら辞書とのマッチを行っていきます。
マッチはmatch()で行います。

    def analyze_tokens(self, toks: list) -> int:  
        """  
        構文木を解析してスコアを返す  

        @param toks トークン列  
        @return スコア  
        """  
        i = 0  
        score = 0  
        titer = TokensIter(toks)  

        while not titer.is_end():  
            score += self.match(titer)  
            titer.next()  

        return score  

match()でイテレーターと辞書を比較

match()でトークン列のイテレーターと辞書を比較します。
内容的には処理はmatch_rows()に委譲しています。
その結果がTrueであれば、対応するデータ(ネガ/ポジのリスト)のスコアを返します。
つまりnegative_wordsにマッチしたら-1を返し、positive_wordsにマッチしたら1を返します。

    def match(self, titer: TokensIter) -> int:  
        """  
        現在のインデックスを基点にしたトークン列が、ネガティブ/ポジティブなワードにマッチするか調べる  

        @param toks トークン列  
        @param index 基点のインデックス  
        @return スコア, 最終的なトークン列のインデックス  
        """  
        is_match = self.match_rows(self.negative_words, titer)  
        if is_match:  
            return -1  

        is_match = self.match_rows(self.positive_words, titer)  
        if is_match:  
            return 1  

        return 0  

match_rows()でイテレーターと辞書を比較

match_rowsではrowstiterの比較を行います。
rowsrowを1行づつ取り出し、そのrowのワードとイテレーターを比較します。
rowがすべてイテレーターにマッチしたら文章のワードが辞書の用言にマッチしたと見なします。
マッチしたらTrueを返し、マッチしなかったらFalseを返します。

マッチした場合は読み込んだ単語分イテレーターを進めます。こうすることで効率よく比較を行うことができます。

    def match_rows(self, rows: list, titer: TokensIter) -> bool:  
        """  
        rowsの中にトークン列が含まれているか調べる  

        @param rows リスト  
        @param toks トークン列  
        @param index 基点のインデックス  
        @return マッチしたかどうかのbool, 最終的なトークン列のインデックス  
        """  
        for row in rows:  
            is_match = True  # マッチしている前提  

            for i in range(len(row)):  
                t = titer.cur(i)  
                if t is None:  
                    is_match = False  # フラグを  
                    break  

                w = row[i]  
                if t.surface != w:  
                    is_match = False  # バッキバキ  
                    break  

            if is_match:  
                titer.index += len(row)  # 読み込んだ分を進める  
                return True  

        return False  

テストで文章を解析

unittest.TestCaseを継承したクラスTestを作ります。
このTesttest_analyze()がテストの実行で実行されます。
test_analyze()の内容的には最初にself.an.load()で辞書を読み込みます。
この「wago.121808.pn」というのはローカルのディスクに保存された こちら の辞書です。

辞書を読み込んだらself.eq()で文章とスコアを比較します。
self.eq()の第1引数には解析させたい文章を渡し、第2引数には期待するスコアを渡します。

self.eq()は内部で第1引数をアナライザーに解析させ、それを第2引数と共にTestCaseのメソッドself.assertEqual()で比較します。
self.assertEqual()は第1引数と第2引数の値が違う場合はエラーを出します。

class Test(unittest.TestCase):  
    def eq(self, a, b):  
        c = self.an.analyze(a)  
        self.assertEqual(c, b)  

    def test_analyze(self):  
        self.an = Analyzer()  
        self.an.load('wago.121808.pn')  

        # ニュートラルなテスト  
        self.eq('今日は買い物に行った', 0)  
        self.eq('今日も通常運転だった', 0)  
        self.eq('朝昼晩ご飯を食べた', 0)  

        # ネガティブなテスト  
        self.eq('今日は物寂しい', -1)  
        self.eq('なんていうか君は偏屈だ', -1)  
        self.eq('君の行いは無作法だ', -1)  
        self.eq('野暮ったいことを言って面白みがない', -2)  

        # ポジティブなテスト  
        self.eq('僕の人生は薔薇色だ', 1)  
        self.eq('気持ちが良く朗らかだ', 1)  
        self.eq('教科書を用いることができる', 1)  
        self.eq('涙ぐましい努力で気持ちが和らぐ', 2)  

        """  
        期待しない結果  
        辞書に↓を追加すれば解析可能  
            ポジ(評価)  楽しかっ た  
            ポジ(評価)  嬉しかっ た  
        """  
        self.eq('楽しかったし嬉しかったです', 0)  # 期待する結果は2  

テストを見ると辞書にある用言には文章がマッチしてスコアを出せています。
しかし当たり前ですが辞書にない言葉にはマッチしないのでスコアはニュートラルになります。

プログラムの実行

テストを実行するにはソースコードをsample.pyなどに保存し、端末からpython -m unittest sampleと言うコマンドを実行します。

$ ls  
sample.py  

$ python -m unittest sample  

プログラムを実行すると↓のような結果になります。

$ python -m unittest sample  
.  
----------------------------------------------------------------------  
Ran 1 test in 1.578s  

OK  

「OK」と出てすべてのテストが成功したことを示しています。

おわりに

今回は辞書を使ったルールベースの感情分析を行ってみました。
感情分析は機械に人間の人間臭い部分を理解させる処理だなーと思いました。

これが発展すれば街中で悲しんでいる人がいたらロボットが飛んできて慰めてくれるなどの近未来が実現するかもしれませんね。

🦝 < それはありがたいのか?

🐭 < ほっといてくれよ