pythonで日本語文の感情分析（＋言語処理の基礎）

この投稿は、[python Advent Calendar](https://qiita.com/advent-calendar/2017/python_python)の9日目として記述しています。


下記のノウハウは、基礎的な言語処理にも使えます。

- [pythonで言語処理するためのライブラリインストール方法（Mecab/Cabocha）](https://qiita.com/matsu0228/items/3b9b7adcddd758cc5980)
- [pythonでデータ処理（ファイルから読み込んだり、mongoDBに保存したり）](https://qiita.com/matsu0228/items/5ba4fe4dfd47dc456c2e)

この記事の成果物として、pythonで日本語を解析するライブラリ(`jpParser`)は、[こちらからご覧ください（あんまり整理しきれてないですが)](https://github.com/matsu0228/nlp-jp/blob/master/lib/jp_parser.py)


# 目的

日本語の感情分析システムを作ります！
用途としては、例えば、twitterで特定アカウントのつぶやきからそのユーザーが怒ってるか喜んでいるかを判定したり、今後主流になるであろうチャット対話や音声対話の機能のうちの一つとしても利用したいところです。

とある理由で調べたのですが、日本語言語処理に関する手法があまりなかったのでまとめてみました。

作るシステムは下記のとおりです。

- 入力は、テキスト（文の区切りがどこかわからない）
- 出力は、スコア`s(-1<s<1)`。-1に近いほどネガティブ、1に近いほどポジティブを意味します。

# 手法

ぱっと思いつく方法は、下記があります。今回は、1のシンプルな方法で実装した例をご紹介します。

1. シンプルな辞書ベース
2. 機械学習を利用して、正解付きコーパスを学習させる
3. 既存のAPIを使う。たとえば、[GCMのNATURAL LANGUAGE API](https://cloud.google.com/natural-language/?hl=ja)

理由：

- ざっと調べたところ、日本語のコーパスは少なそうで、教師あり機械学習をするにも、自分で正解データを作成する必要あったため。利用可能そうな資源を注釈に上げておきます。[^1]
- GCMだと面白くないwちなみに、GCMだと5k/月のテキストまでは無料ですが、本格的に使うと料金がそれなりにかかります。

### 実装手順

英語だと、`nltk`という言語処理ライブラリの一部として、`nltk.sentiment.vader`[^2] があります。基本方針としては、これを参考に、下記の流れで実装します。

1. 文分割
2. 形態素解析・構文解析
3. 感情辞書との照合
4. ルールベースの適用
5. スコアの算出

また、日本語の極性辞書としては、下記がメジャーそうですが、「日本語評価極性辞書」を利用します。※英語のライブラリは充実してるのに対し、日本語は少ないなと実感とします。。

- 東北大の乾・岡崎研究室が公開されている「日本語評価極性辞書」
 - [日本語評価極性辞書](http://www.cl.ecei.tohoku.ac.jp/index.php?Open%20Resources%2FJapanese%20Sentiment%20Polarity%20Dictionary)
 
- 東工大の高村教授が公開されている「単語感情極性対応表」
 - [単語感情極性対応表](http://www.lr.pi.titech.ac.jp/~takamura/pndic_ja.html)



なお、文分割前に、前処理が大切みたいです。今回は割愛。前処理は下記の投稿が非常に参考になります。
[自然言語処理における前処理の種類とその威力](https://qiita.com/Hironsan/items/2466fe0f344115aff177)

# 実装

## 1.文分割

非常にシンプルですが、文末記号を定義して、抽出します。
ライターが書いたレビューなど「カタい」文章だとうまくいきますが、Twitterなどではうまくいかないことも多いです。今回は、下記の文字を文末記号として定義しました。
`　EOS_DIC = ['。', '．', '！','？','!?', '!', '?' ]　`

## 2.形態素解析・構文解析

形態素解析にはMecab、構文解析にはCaboChaを利用しました。
他の方法として、JUMAN++とKNPを用いる方法もあります。

インストールは、こちらを参考に。
[pythonで言語処理するためのライブラリインストール方法（Mecab/Cabocha）](https://qiita.com/matsu0228/items/3b9b7adcddd758cc5980)

### コードサンプル

```python/jp_parser.py

import MeCab
import CaboCha
from collections import namedtuple

class JpParser:
  """
  return parsed data with Mecab
  """
  POS_DIC = {
    'BOS/EOS': 'EOS', # end of sentense
    '形容詞' : 'ADJ',
    '副詞'   : 'ADV',
    '名詞'   : 'NOUN',
    '動詞'   : 'VERB',
    '助動詞' : 'AUX',
    '助詞'   : 'PART',
    '連体詞' : 'ADJ', # Japanese-specific POS
    '感動詞' : 'INTJ',
    '接続詞' : 'CONJ',
    '*'      : 'X',
  }

  def __init__(self, * ,sys_dic_path=''):
    opt_m = "-Ochasen"
    opt_c = '-f4'
    if sys_dic_path:
      opt_m += ' -d {0}'.format(sys_dic_path)
      opt_c += ' -d {0}'.format(sys_dic_path)
    tagger = MeCab.Tagger(opt_m)
    tagger.parse('') # for UnicodeDecodeError
    self._tagger = tagger
    self._parser = CaboCha.Parser(opt_c)
    
  def get_sentences(self, text):
    """ 
    input: text have many sentences
    output: ary of sentences ['sent1', 'sent2', ...]
    """
    EOS_DIC = ['。', '．', '！','？','!?', '!', '?' ]
    sentences = list()
    sent = ''
    for token in self.tokenize(text):
      # print(token.pos_jp, token.pos, token.surface, sent)
      # TODO: this is simple way. ex)「今日は雨ね。」と母がいった
      sent += token.surface
      if token.surface in EOS_DIC and sent != '':
        sentences.append(sent)
        sent = ''
    return sentences
    

  def tokenize(self, sent):
    node = self._tagger.parseToNode( sent )
    tokens = list()
    idx = 0
    while node:
      feature = node.feature.split(',')
      token = namedtuple('Token', 'idx, surface, pos, pos_detail1, pos_detail2, pos_detail3,\
                          infl_type, infl_form, base_form, reading, phonetic')
      token.idx         = idx
      token.surface     = node.surface  # 表層形
      token.pos_jp      = feature[0]    # 品詞
      token.pos_detail1 = feature[1]    # 品詞細分類1
      token.pos_detail2 = feature[2]    # 品詞細分類2
      token.pos_detail3 = feature[3]    # 品詞細分類3
      token.infl_type   = feature[4]    # 活用型
      token.infl_form   = feature[5]    # 活用形
      token.base_form   = feature[6]    # 原型
      token.pos         = self.POS_DIC.get( feature[0], 'X' )     # 品詞
      token.reading     = feature[7] if len(feature) > 7 else ''  # 読み
      token.phonetic    = feature[8] if len(feature) > 8 else ''  # 発音
      #
      tokens.append(token)
      idx += 1
      node = node.next
    return tokens
    
if __name__ == "__main__":
  jp = JpParser( sys_dic_path='/usr/local/lib/mecab/dic/mecab-ipadic-neologd')
  # Japanese famous poem written by Soseki natusme.
  sentences = jp.get_sentences('我輩は猫である。名前はまだ無い。どこで生れたかとんと見当けんとうがつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。')
  for sent in sentences:
    # token --------------------------------------
    sent_data = jp.tokenize(sent)
    for s in sent_data:
      print(s.surface, s.base_form, s.pos)
    

```



## 3.感情辞書との照合

下記の辞書（名詞と用言）をそのまま利用しました。
positive/neutral/negativeのいずれかのラベルが付与された辞書なので、シンプルに、-1,0,1に変換して、辞書をmongoDBに突っ込みました。
 - [日本語評価極性辞書](http://www.cl.ecei.tohoku.ac.jp/index.php?Open%20Resources%2FJapanese%20Sentiment%20Polarity%20Dictionary)


ファイルを読み込んだり、mongoDBへ保存・読み込みは、下記の通り
->[pythonでデータ処理（ファイルから読み込んだり、mongoDBに保存したり）](https://qiita.com/matsu0228/items/5ba4fe4dfd47dc456c2e)

文ごとの形態素が、極性辞書に含まれるか判定し、下記のとおりとします。

- 含まれたらそのスコア(-1 or 0 or 1)
- 含まれなければ、0

### コードサンプル

- db接続ライブラリ

```python/database.py
import os, sys, logging, time, configparser
from pymongo import MongoClient,  DESCENDING
import pandas as pd

# Const of database name
DICTIONARY_DB = "dictionaries"
PL_COLLECTION_NAME = "polarity"

# db接続情報は、`config.ini`に外だししておく
# --config.ini sample--------
# [mongo]
# id=**
# password=**
def get_db(db_name):
    config = configparser.ConfigParser()
    config.read( './config.ini')
    client = MongoClient('localhost')
    client['admin'].authenticate(config.get('mongo', 'id'), config.get('mongo', 'password'))
    db = client[db_name]
    return db

# mongoDBのスキーマ
# ------------
# データベース名：dictionarries
# |- コレクション名：polarity
#    |- ドキュメント フォーマット： headword, score, ...
# （ドキュメント例： {headword="悪い", score=-1}, ...） 
def load_polarity_dic(collection_name):
    db = get_db(DICTIONARY_DB)
    cursor = db[collection_name].find()
    df = pd.DataFrame.from_dict(list(cursor)).astype(object)
    return df

```

- 呼び出し側

```python/jp_parser.py

class JpParser:
    def __init__(self): 
        ...(省略)...
        self.pol_dic = load_politly_dic("polarity")

    def search_politely_dict(self, words):
        politely_dict = dict()
        default_score = 0  # return score when not found in plitely dict
        for w in words:
            res = self.pol_dic[self.pol_dic['headword']==w]
            if len(res.index) > 0:
                politely_dict.update({res['headword'].values[0]: res['score'].values[0]})
            else:
                politely_dict.update({w:default_score})
        return politely_dict
```

## 4.ルールベースの適用

たとえば、「このスーツは、私には合いませんでした」が入力であった場合、
極性辞書には、`「合う」 = positive`のみしか含まれていないため、文全体がポジティブだと判定されてしまいます。今回は、下記の表現が出現した場合は、極性辞書のスコアを逆転させるようにしました。
上記の例では、`「合う」 = positive`に、`ない(助動詞)`が続くので、`合わない`全体で-1のスコアにするようなルールを追加しました。

```
ない(助動詞)
ぬ(助動詞)
ない(形容詞)

の で は ない(複合辞=複数の形態素列)
わけ で は ない(複合辞)
わけ に は いく ない(複合辞)
```

### コードサンプル

```python/jp_parser.py
def apply_politely_reverse_rule_for_senti_analisys(self, i, tokens, scores, sentence)
        reverse_multiwords = [
            # headword,N-gram,apply_type
            ['の で は ない',       3, 'own'],
            ['わけ で は ない',     3, 'own'],
            ['わけ に は いく ない',4, 'src'],
        ]
        reverse_words = [
            # headword,pos,apply_type
            ['ない', 'AUX', 'own'],
            ['ぬ',   'AUX', 'own'],
            ['ない', 'ADJ', 'own'],
        ]
        apply_type = ''
        # detect politely-reverse word ( like a 'not' )
        # -------------------------------------------------------------------
        for r in reverse_words:
            if tokens[i].base_form==r[0] and tokens[i].pos==r[1]:
                apply_type = r[2]
        for r in reverse_multiwords:
            if i >= r[1]:
                multi_words = [ x.base_form.lower() for x in tokens if i-r[1] <= x.idx <= i]
                if ' '.join(multi_words) == r[0]:
                    apply_type = r[2]
        # apply for score
        # -------------------------------------------------------------------
        if apply_type!='':
            chunk = self.get_chunk_data(sentence)
            for j in range(0,len(chunk)):
                c = chunk[j]
                if c.token_idx <= i <= c.token_idx+c.token_size-1:
                    if apply_type=='own':
                        start_idx, end_idx = c.token_idx, (c.token_idx+c.token_size)
                    elif apply_type=='src':
                        sc = chunk[c.src_idx[-1]]
                        start_idx, end_idx = sc.token_idx, (sc.token_idx+sc.token_size)
                    # elif apply_type=='depend':
                    max_score_of_reverse = max(scores[start_idx:end_idx])
                    del scores[start_idx:end_idx]
                    scores.append(-1*int(max_score_of_reverse))
                    scores.extend([0 for i in range(end_idx-start_idx-1)])
                    break
        return scores
```

## 5.スコアの算出

出力は、スコア`s(-1<s<1)`となるように設計したいので、下記の式で文ごとのスコアを算出しました。

```
スコア = 形態素ごとのスコアの総和　/　文全体の形態素数
```

### コードサンプル

```python/jp_parser.py
    def senti_analysis(self, sentence):
        """
        output: sentiment score (1:positive < score < -1:negative)
        """
        score = 0
        num_all_words = 0
        tokens = self.tokenize(sentence)
        words = list()
        words.extend([s.base_form.lower() for s in tokens])
        politely_dict = self.search_politely_dict(words)
        scores = list()
        scores.extend([politely_dict[w] for w in words]
        for i in range(0, len(tokens)): # apply rules
            s = tokens[i]
            scores = self.apply_politely_reverse_rule_for_senti_analisys(i, tokens, scores, sentence)
        # evaluate score
        # ----------------
        for sc in scores:
            score += sc
            num_all_words += 1
        return round(score/num_all_words, 2)
```

## JpParserのコードサンプル

* せいりされてないですがこちらにあります

https://github.com/matsu0228/nlp-jp/blob/master/lib/jp_parser.py

# 参考

[^1]: 感情分析などに利用可能そうな言語資源：[言語資源一覧](http://masatohagiwara.net/nltk-japanese-corpus.html)、 [楽天レビュー（研究目的に限る）](http://www.nii.ac.jp/dsc/idr/datalist.html)


[^2]: VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text：http://comp.social.gatech.edu/papers/icwsm14.vader.hutto.pdf