この投稿は、python Advent Calendarの9日目として記述しています。
下記のノウハウは、基礎的な言語処理にも使えます。
この記事の成果物として、pythonで日本語を解析するライブラリ(jpParser
)は、こちらからご覧ください(あんまり整理しきれてないですが)
目的
日本語の感情分析システムを作ります!
用途としては、例えば、twitterで特定アカウントのつぶやきからそのユーザーが怒ってるか喜んでいるかを判定したり、今後主流になるであろうチャット対話や音声対話の機能のうちの一つとしても利用したいところです。
とある理由で調べたのですが、日本語言語処理に関する手法があまりなかったのでまとめてみました。
作るシステムは下記のとおりです。
- 入力は、テキスト(文の区切りがどこかわからない)
- 出力は、スコア
s(-1<s<1)
。-1に近いほどネガティブ、1に近いほどポジティブを意味します。
手法
ぱっと思いつく方法は、下記があります。今回は、1のシンプルな方法で実装した例をご紹介します。
- シンプルな辞書ベース
- 機械学習を利用して、正解付きコーパスを学習させる
- 既存のAPIを使う。たとえば、GCMのNATURAL LANGUAGE API
理由:
- ざっと調べたところ、日本語のコーパスは少なそうで、教師あり機械学習をするにも、自分で正解データを作成する必要あったため。利用可能そうな資源を注釈に上げておきます。1
- GCMだと面白くないwちなみに、GCMだと5k/月のテキストまでは無料ですが、本格的に使うと料金がそれなりにかかります。
実装手順
英語だと、nltk
という言語処理ライブラリの一部として、nltk.sentiment.vader
2 があります。基本方針としては、これを参考に、下記の流れで実装します。
- 文分割
- 形態素解析・構文解析
- 感情辞書との照合
- ルールベースの適用
- スコアの算出
また、日本語の極性辞書としては、下記がメジャーそうですが、「日本語評価極性辞書」を利用します。※英語のライブラリは充実してるのに対し、日本語は少ないなと実感とします。。
なお、文分割前に、前処理が大切みたいです。今回は割愛。前処理は下記の投稿が非常に参考になります。
自然言語処理における前処理の種類とその威力
実装
1.文分割
非常にシンプルですが、文末記号を定義して、抽出します。
ライターが書いたレビューなど「カタい」文章だとうまくいきますが、Twitterなどではうまくいかないことも多いです。今回は、下記の文字を文末記号として定義しました。
EOS_DIC = ['。', '.', '!','?','!?', '!', '?' ]
2.形態素解析・構文解析
形態素解析にはMecab、構文解析にはCaboChaを利用しました。
他の方法として、JUMAN++とKNPを用いる方法もあります。
インストールは、こちらを参考に。
pythonで言語処理するためのライブラリインストール方法(Mecab/Cabocha)
コードサンプル
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に突っ込みました。
ファイルを読み込んだり、mongoDBへ保存・読み込みは、下記の通り
->pythonでデータ処理(ファイルから読み込んだり、mongoDBに保存したり)
文ごとの形態素が、極性辞書に含まれるか判定し、下記のとおりとします。
- 含まれたらそのスコア(-1 or 0 or 1)
- 含まれなければ、0
コードサンプル
- db接続ライブラリ
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
- 呼び出し側
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のスコアにするようなルールを追加しました。
ない(助動詞)
ぬ(助動詞)
ない(形容詞)
の で は ない(複合辞=複数の形態素列)
わけ で は ない(複合辞)
わけ に は いく ない(複合辞)
コードサンプル
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)
となるように設計したいので、下記の式で文ごとのスコアを算出しました。
スコア = 形態素ごとのスコアの総和 / 文全体の形態素数
コードサンプル
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のコードサンプル
- せいりされてないですがこちらにあります
参考
-
感情分析などに利用可能そうな言語資源:言語資源一覧、 楽天レビュー(研究目的に限る) ↩
-
VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text:http://comp.social.gatech.edu/papers/icwsm14.vader.hutto.pdf ↩