ちょっとした拍子に日本語の極性分析 (感情分析) について知り、素人ながらちょこちょこと遊ばせていただきました。
一般的な極性分析 (感情分析) では、単語ごとに極性辞書と照らし合わせたスコアを、文全体で平均しています。 ですが、それで必ずしも直感に即したスコアが出ているのでしょうか……?
自然言語処理 Advent Calendar 2019 の 4 日目です。 昨日は kabayan55 さんの “「自然言語処理の勉強をしたい人」を対象に登壇した話” と Kazumasa Yamamoto さんの “spaCyのCLIで文書のカテゴリ分類を学習する” でした。
初めての Advent Calendar 参加となりますがどうぞよろしくお願いいたします。
まず、極性分析とは
単語にはポジティブな印象の単語と、ネガティブな印象の単語があると考える方は多いでしょう。 たとえば “明るい” はポジティブな印象を抱く人が多く、“暗い” はネガティブ、といったようなものです。
そういった単語ごとの印象 (極性) をまとめ、さらに文に登場する各単語の極性を分析していく……というものが極性分析になります。
極性分析はそのまま感情分析の足がかりとして使われます。 全体的にポジティブな文章の場合、書き手 (話し手) はポジティブな感情を持っている……というのは妥当な推論でしょう。 京都人みたいなのはまあ……。
この一連のプロセスにおいてまず辞書の妥当性、つまり単語の極性が正しく評価されているかどうかは非常に重要な観点ではありますが、今回はそこには触れずその後の文全体の極性を考える部分に着目していきます。
Python 用極性評価ライブラリ “oseti”
極性辞書にもいろいろありますが、今回は乾・鈴木研究室で公開されている日本語評価極性辞書を使用しようと思います。 この辞書は単語ごとにポジティブなら 1、ネガティブなら -1 とスコア付けされています。
この極性辞書を Python で容易に利用可能にしたライブラリが Qiita でも発表されている “oseti” です。 MeCab による形態素解析を経て、文章のスコアを算出してくれます。
ためしに利用してみましょう。 MeCab のインストールについては省略します。 oseti は PyPI に登録されていますので、pip
コマンドでインストールできます。
pip install oseti
Analyzer
インスタンスを作成し、これに対してメソッド呼び出しを行うことでスコアを算出できます。
import oseti
analyzer = oseti.Analyzer()
とりあえず、かの名文の序盤を渡してみましょう。
吾輩は猫である。名前はまだ無い。
どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。――“吾輩は猫である” - 夏目漱石
iamcat = '吾輩は猫である。名前はまだ無い。\n \
どこで生れたかとんと見当がつかぬ。 \
何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。 \
吾輩はここで始めて人間というものを見た。 \
しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。 '
iamcat_score = analyzer.analyze(iamcat)
print(iamcat_score)
[0, 0, 0, -1.0, 0, 1.0]
3 番目の文章、
何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。
がネガティブと判定されているのはなんとなくわかりますが、5 番目の文章、
しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。
がポジティブ扱いなのは少し不思議です。 こういう場合は詳細分析を行うメソッドを呼び出してみましょう。
iamcat_detail = analyzer.analyze_detail(iamcat)
print(iamcat_detail[3])
print(iamcat_detail[5])
{'positive': [], 'negative': ['薄暗い'], 'score': -1.0}
{'positive': ['一番'], 'negative': [], 'score': 1.0}
なるほど、一番という単語がポジティブ扱いされている理由のようです。
もうすこし、ポジティブな単語とネガティブな単語が混在した文を渡してみます。
test_text = '新しく買ったスマホ、出費はでかかったけど動作が軽快で快適。'
test_score = analyzer.analyze(test_text)
test_detail = analyzer.analyze_detail(test_text)
print(test_score)
print(test_detail)
[0.3333333333333333]
[{'positive': ['軽快', '快適'], 'negative': ['出費'], 'score': 0.3333333333333333}]
各単語のスコアが平均されて出てきました。 平均はほかの極性分析に関わる記事でもよく使われている、標準的な手法のようです。
平均すると困る場合
さて、ここに架空の登場人物、佐藤さんと鈴木さんがいます。 二人の会話はこうです。
佐藤「この間の出張ついでに A 市に遊びに行ったよ。 よく道に迷ったのと人が多くて疲れたけど、食べ物も美味しかったし景色もよかったぜ」
鈴木「それはよかった。 俺は B 市に行ったんだが、景色はいいし食べ物は美味しかったけど、疲れたしどこに行っても迷ってしまったからなぁ」
佐藤さんは観光を楽しんだようですが鈴木さんはいまいちだったようです。
しかし、この二人の発言を極性分析するとこうなってしまいます。
sato_remark = 'よく道に迷ったのと人が多くて疲れたけど、食べ物も美味しかったし景色もよかった'
suzuki_remark = '景色はいいし食べ物は美味しかったけど、疲れたしどこに行っても迷ってしまった'
sato_score = analyzer.analyze(sato_remark)
suzuki_score = analyzer.analyze(suzuki_remark)
print(F'佐藤: {sato_score}')
print(F'鈴木: {suzuki_score}')
佐藤: [0.0]
鈴木: [0.0]
二人ともまったく同じく、プラマイゼロとなってしまいました。 この理由は詳細分析のメソッドを呼び出すとわかります。
sato_detail = analyzer.analyze_detail(sato_remark)
suzuki_detail = analyzer.analyze_detail(suzuki_remark)
print(F'佐藤: {sato_detail}')
print(F'鈴木: {suzuki_detail}')
佐藤: [{'positive': ['美味しい', '景色'], 'negative': ['迷う', '疲れる'], 'score': 0.0}]
鈴木: [{'positive': ['景色', '美味しい'], 'negative': ['疲れる', '迷う'], 'score': 0.0}]
はい、二人ともスコア付けされた単語に関しては同じものを使っています。 要するに、言っている内容は同じなのです。
それでも我々は佐藤さんの発言はポジティブで、鈴木さんの発言はネガティブに感じます。 その理由はなんでしょうか。
一つの仮説として、話題の順序が挙げられるでしょう。 少なくとも日本人は、より自分が伝えたい事柄をあとに回す傾向があるように思われます。 佐藤さんにとって迷ったことや疲れたことは食べ物の美味しさや景色の美しさにくらべれば大して重要ではなく、鈴木さんにとっては逆なのだ、と考えるのは自然なことです。
出現順序で重み付けをする
というわけで、単語の出現順序で重み付けを行うメソッドを追加してみましょう。
import neologdn
import sengiri
def analyze_with_weight(self, text, weightfunc=None):
if weightfunc is None:
weightfunc = lambda x: [1 / x for _ in range(x)]
text = neologdn.normalize(text)
scores = []
for sentence in sengiri.tokenize(text):
polarities = self._calc_sentiment_polarity(sentence)
if polarities:
weights = weightfunc(len(polarities))
scores.append(sum(weights[i] * p[1] for (i,p,) in enumerate(polarities)))
else:
scores.append(0)
return scores
setattr(oseti.Analyzer, 'analyze_with_weight', analyze_with_weight)
weightfunc
には整数を与えると、その要素数を持つ一次元のシーケンス (リストやタプル、NumPy テンソルでも構いません) を返却する関数を与えます (合計が 1 になることを期待しますが特にチェックは行っていません)。 これが出現順序による重みになります。 もし省略された場合は一様な重みになります。 これは平均をとっているのと同じことになります。
たとえば、線形増加する重みとして以下を与えてみましょう。
from fractions import Fraction
def linear_weight(x):
l = [i for i in range(1, x + 1)]
s = sum(l)
return [Fraction(i, s) for i in l]
この重みを使って二人の発言を分析するとこうなります。
sato_score = analyzer.analyze_with_weight(sato_remark, linear_weight)
suzuki_score = analyzer.analyze_with_weight(suzuki_remark, linear_weight)
print(F'佐藤: {sato_score}')
print(F'鈴木: {suzuki_score}')
佐藤: [Fraction(2, 5)]
鈴木: [Fraction(-2, 5)]
……おっと有理数のままでした。
sato_score = [float(i) for i in sato_score]
suzuki_score = [float(i) for i in suzuki_score]
print(F'佐藤: {sato_score}')
print(F'鈴木: {suzuki_score}')
佐藤: [0.4]
鈴木: [-0.4]
ということで、佐藤さんの発言は比較的ポジティブと、鈴木さんの発言は比較的ネガティブと判定されました。 これは最初の結果よりも直感に即しているのではないでしょうか。
しかし言葉とはそんな簡単なものでもない
とはいえ、この方法がすべてを解決するかといえばそういうわけでもありません。
score = analyzer.analyze_with_weight('どちらかといえば心地良い疲れだ。', linear_weight)
score = [float(i) for i in score]
print(score)
[-0.3333333333333333]
この例や、撞着語法などは、前に来る形容詞こそ本当に意味することだったりする場合があります。 そういった機微を考えず単純に後ろほど重くするのは、むしろ乱暴かもしれません。 “文脈” を読まなければいけないわけです。
結局、より “正しい” 評価を得たいのならばどんどんと手法は複雑化していきます。 しかし、
Statistically sophisticated or complex methods do not necessarily provide more accurate forecasts than simpler ones. ―― 統計学的に洗練されていたり複雑な方法というのが簡潔な方法よりも正確な予測をするとは限らない。
――“The M3-Competition: results, conclusions and implications” - Spyros Makridakis, Michele Hibon
同様に、あまりに複雑な方法をとったところで結果が良くなるとは限りません。 というよりは、良い結果が出たとしてもそれに見合わないコストを支払っていては意味がないのです。
そういう意味で、“平均” というのは極めて単純であり、そこそこの結果を出してくれる良い方法である……といえるのかもしれません。
おわりに
というわけで、実は自然言語処理のしの字も知らないようなたぬきによる雑文でした。
明日は Mona Cat さんです。