感情分類は以下の2通りに大分けされる模様
- positive/negativeの二値分類(neutralを含める場合もあり)
- joy, sadnessなど複数の感情に分類
このうち特に2.についてはapiとして公開されているものもそこまで多くない印象なのでこちらについてやってみることにした
簡単に論文を調べてみると、やはり感情分類も大規模な事前学習済みモデルに対してfinetuneするのがよさそう
https://arxiv.org/abs/1812.01207
↑の概要としては
- amazon reviewのテキスト40GBで事前学習(transfomerやLSTMなど複数モデル)
- semeval2018やcompany tweetsなどいくつかのデータセットでfinetune
- データセット毎にパフォーマンス比較しtransformerが良い結果
- apiとして公開されているWatsonよりも大幅に良い
ということで同じ方向でやる。ただし、事前学習を自前でやるのはコストがかかりすぎるためpublicに上がっているbertの日本語学習済みモデルを利用する
(ニュースやwikiなどに比べてamazon reviewは感情を多めに含むため感情分類の事前学習として効果が高い、と書かれているがさすがに日本語のamazon review学習済みモデルなど見つからなかった)
データセット
英語であれば感情分類用のデータセットがいくつかあるみたいだったため、今回はそれを日本語に翻訳して試してみることにした
できるだけ多くデータがあるものがよいと思い調べてみたが、こちらで複数のデータセットを集めてくれている方がいた
https://github.com/abishekarun/Text-Emotion-Classification
この中でequity evaluation corpusは同じ文の細かい部分を変えただけのものが大量に並んでいるものだったため除外し、text_emotion.csvとtweets_clean.txtを使う(合わせて5万件程度になる)
前処理(ラベル統合)
まず学習に使うラベルを限定する こちらのようにした
data = pd.read_csv(args.input_path, sep='\t', encoding='utf-8', names=['text', 'label']) # 不必要なlabelを除外 data = data[data.label != 'neutral'] data = data[data.label != 'empty'] # labelをまとめる data.label = np.where(data.label == 'enthusiasm', 'joy', data.label) data.label = np.where(data.label == 'love', 'joy', data.label) data.label = np.where(data.label == 'fun', 'joy', data.label) data.label = np.where(data.label == 'relief', 'joy', data.label) data.label = np.where(data.label == 'happiness', 'joy', data.label) data.label = np.where(data.label == 'hate', 'disgust', data.label) data.label = np.where(data.label == 'worry', 'disgust', data.label) data.label = np.where(data.label == 'boredom', 'sadness', data.label) data.label = np.where(data.label == 'fear', 'surprise', data.label) data.to_csv(args.output_path, sep='\t', header=False, index=False)
最終的に使うラベルはanger, disgust, joy, sadness, surprisedの5つ
データ数が近くなるようにマージした方が学習としてはよいのだが、使うことを考えるとangerやsurprisedは入れておきたかったのでデータ数は少ないが残した
前処理(クリーニング)
こんな処理を入れた
#/bin/bash src_path=$1 src_name=$(basename $src_path) if [ $src_name == 'tweets_clean.txt' ]; then cat $src_path \ | awk -F'\t' '{print($2, "\t", $3)}' \ # ラベルの頭に::が付いているため除去 # ダブルクォーテーションを除去 | sed -e 's/\t\s*::\s*/\t/' -e 's/"//g' \ # urlエンコードされている文字列を復元 | sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g' -e "s/"/'/g" exit fi if [ $src_name == 'text_emotion.csv' ]; then cat $src_path \ # ヘッダーを除く | tail -n +2 \ # csvをtsvへ変換 | python3 -c 'import csv, sys; csv.writer(sys.stdout, dialect="excel-tab").writerows(csv.reader(sys.stdin))' \ # 制御文字とダブルクォーテーションを除去 | sed -e 's/^M//g' -e 's/"//g' \ # urlエンコードされている文字列を復元 | sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g' -e "s/"/'/g" \ | awk -F'\t' '{print $4"\t"$2}' exit fi
ファイル毎にフォーマットが異なるのでそれぞれ処理した
翻訳
google翻訳よりも自然な日本語が出てくると思うのでDeepLで翻訳
こんな記事を書いてくれている方がいて大変助かりました
https://self-development.info/google%E7%BF%BB%E8%A8%B3%E3%82%88%E3%82%8A%E5%84%AA%E3%82%8C%E3%81%9Fdeepl%E7%BF%BB%E8%A8%B3%E3%81%A7%E8%87%AA%E5%8B%95%E7%BF%BB%E8%A8%B3%E3%80%90%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3/
サーバに負荷をかけないよう手作業と同程度ということで5秒に一回のペースでリクエスト
ちなみに、2000リクエスト程度投げた時点でしばらくレスポンスが返ってこなる。15分くらいするとまた返ってくるようになるが、再度2000リクエスト程度投げるとまた返ってこなる。すると今度は60分くらい経つと再びレスポンスが来るようになる。という感じで必要な待ち時間が徐々に長くなっていくので適当なところでプロセスを再実行する必要があった(再実行した場合はすぐに返ってくる)
実装
transformersを利用。文章の分類を行うためのクラスが用意されておりラベル数指定で簡単にheadを追加して分類の学習ができる https://huggingface.co/transformers/model_doc/bert.html#bertforsequenceclassification
また、東北大で作成されたbertの日本語学習済みモデルも利用可能
https://huggingface.co/cl-tohoku/bert-base-japanese-whole-word-masking
どうなっているのかの把握のため中を見てみる。Headはこちら
https://huggingface.co/transformers/modules/transformers/models/bart/modeling_bart.html#BartForSequenceClassification.forward
bertではなくbartのドキュメントを見てたことに気づいたため以下で書いたそのあたりの説明を修正しました
https://huggingface.co/transformers/modules/transformers/models/bert/modeling_bart.html#BartForSequenceClassification.forward
class BertForSequenceClassification(BertPreTrainedModel): def __init__(self, config): super().__init__(config) self.num_labels = config.num_labels self.bert = BertModel(config) self.dropout = nn.Dropout(config.hidden_dropout_prob) self.classifier = nn.Linear(config.hidden_size, config.num_labels) self.init_weights() def forward(self, ...): ... outputs = self.bert( input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, position_ids=position_ids, head_mask=head_mask, inputs_embeds=inputs_embeds, output_attentions=output_attentions, output_hidden_states=output_hidden_states, return_dict=return_dict, ) pooled_output = outputs[1] pooled_output = self.dropout(pooled_output) logits = self.classifier(pooled_output) ... class BertPooler(nn.Module): def __init__(self, config): super().__init__() self.dense = nn.Linear(config.hidden_size, config.hidden_size) self.activation = nn.Tanh() def forward(self, hidden_states): # We "pool" the model by simply taking the hidden state corresponding # to the first token. first_token_tensor = hidden_states[:, 0] pooled_output = self.dense(first_token_tensor) pooled_output = self.activation(pooled_output) return pooled_output
pooled_outputというのはBertの最終的な出力から最初のtokenの結果だけを抽出したもの(BertModelの最終層の出力をBertPoolerに通したもの)
headの処理としてはそれをdropoutにかけてから全結合層に通すだけのシンプルなものだった。
自分で書いた部分のコードでちょっと工夫した点として、高速化のためapexを入れてfp16対応している。以前書いた記事でやったのとまったく同じで簡単にできた
https://jsapachehtml.hatenablog.com/entry/2020/05/04/200757#fp16%E5%AF%BE%E5%BF%9C
ちなみに後から気づいたが、transformersにはTrainerの実装も用意されていたようでこれを使えばもっとスマートにできたはず
https://huggingface.co/transformers/main_classes/trainer.html
今度何かあれば使ってみる
今回実装したコードの全体はこちら
https://github.com/y-kamiya/emotion-classification/blob/c7d98b2/trainer.py
評価
学習部分は特別なことをしておらず簡単だったため、どちらかというと評価用の出力を出す部分で時間がかかった
どのラベルが他のラベルと間違いやすいのかという点を知りたいため評価時のログとtensorboardに混同行列を出力
from sklearn import metrics import pycm class EmotionDataset(Dataset): label_index_map = { 'anger': 0, 'disgust': 1, 'joy': 2, 'sadness': 3, 'surprise': 4, } ...(以下略) def __log_confusion_matrix(self, all_preds, all_labels, epoch): label_map = {value: key for key, value in EmotionDataset.label_index_map.items()} # 混同行列を作成 cm = metrics.confusion_matrix(y_pred=all_preds.numpy(), y_true=all_labels.numpy(), normalize='true') display = metrics.ConfusionMatrixDisplay(cm, display_labels=label_map.values()) display.plot(cmap=plt.cm.Blues) # tensorboardへ出力 buf = io.BytesIO() display.figure_.savefig(buf, format="png", dpi=180) buf.seek(0) img_arr = np.frombuffer(buf.getvalue(), dtype=np.uint8) buf.close() img = cv2.imdecode(img_arr, 1) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) self.writer.add_image('confusion_maatrix', img, epoch, dataformats='HWC') # コンソールログ用の混同行列 cm = pycm.ConfusionMatrix(actual_vector=all_labels.numpy(), predict_vector=all_preds.numpy()) cm.relabel(mapping=label_map) cm.print_normalized_matrix()
こちらを参考に混同行列の画像をnumpy arrayとしてtensorboardへ出力
https://jun-networks.hatenablog.com/entry/2019/11/01/020536
また、コンソールログへの表示用としてpycmを使ってみた
https://blog.ikedaosushi.com/entry/2019/05/27/102818
各種スコアも出すためtabulateでpandasのDataFrameを出力した
columns = EmotionDataset.label_index_map.keys() df = pd.DataFrame(metrics.classification_report(all_labels, all_preds, output_dict=True)) print(tabulate(df, headers='keys', tablefmt="github", floatfmt='.2f'))
この際、colaboratory上で表の表示が崩れて見づらかったがブラウザのfont指定がおかしいためだった
https://jsapachehtml.hatenablog.com/entry/2020/12/31/152704?_ga=2.25166645.904288289.1610768063-1050067043.1602897991
実験
colaboratory上で実行
!python trainer.py --dataroot "$DATAROOT" --n_labels 5 --batch_size 96 --epochs 30 --fp16 --log_interval 120 --eval_interval 2 --lr 1e-5 --name lr1e-5 !python trainer.py --dataroot "$DATAROOT" --n_labels 5 --batch_size 96 --epochs 30 --fp16 --log_interval 120 --eval_interval 2 --lr 5e-6 --name lr5e-6
データセットは以下のようなフォーマットでtrain.txt, eval.txt(実際はtsvだが)として$DATAROOTで指定したディレクトリ直下に置いてある。
@tommcfly トム 準備してください ここポルトアレグレは本当に寒いです disgust ...
データ数は全体の10%程度を評価用に使ったので以下のよう
- train.txt: 46235
- eval.txt: 4962
結果がこちら
2 epoch実行した時点でf1 score(macro): 0.436が最大値。評価データへのlossもその後は上昇しており過学習しているといえる。
同じ時点での他の指標はこちら
Predict anger disgust joy sadness surprise Actual anger 0.16779 0.20805 0.21477 0.12752 0.28188 disgust 0.00498 0.39343 0.33964 0.18227 0.07968 joy 0.00143 0.09685 0.73998 0.06679 0.09494 sadness 0.00585 0.24122 0.27986 0.36885 0.10422 surprise 0.00815 0.13388 0.35623 0.08498 0.41676 | | 0.0 | 1.0 | 2.0 | 3.0 | 4.0 | accuracy | macro avg | weighted avg | |-----------|--------|---------|---------|--------|--------|------------|-------------|----------------| | precision | 0.56 | 0.42 | 0.63 | 0.43 | 0.47 | 0.53 | 0.50 | 0.52 | | recall | 0.17 | 0.39 | 0.74 | 0.37 | 0.42 | 0.53 | 0.42 | 0.53 | | f1-score | 0.26 | 0.40 | 0.68 | 0.40 | 0.44 | 0.53 | 0.44 | 0.52 | | support | 149.00 | 1004.00 | 2096.00 | 854.00 | 859.00 | 0.53 | 4962.00 | 4962.00 |
最後の表の中のaccuracyはf1-score(micro)と同等のもの。今回はラベル毎のデータ数に大きな違いがあるのでこちらで評価した方がよかったかもしれない。
ちなみにf1-score(micro)も上記に書いた2 epoch目が最大値だった。
tensorboardに上げた混同行列はこんな感じになって見やすい
やはりデータ数の多いjoyが特に精度高くなった。というより全体的にjoyである確率が高めになっており、これはデータ数的にjoyと答えておけば正答率が高くなるという学習の仕方になっているのかも。
ただ、anger以外については真のラベルに合致するものがちゃんと予測確率として最大にはなっているのである程度の学習はできていそう。最も少ないangerについては残念な結果。
実際にeval.txtで予測したデータを確認してみると以下のような感じだった
正解していたデータ
# 左から順に、予測ラベル/各ラベルの確率(%)/真のラベル/テキスト # 数字は表の並びと同じでanger, disgust, joy, sadness, surprised disgust [ 1. 59. 13. 23. 4.] disgust @tommcfly トム 準備してください ここポルトアレグレは本当に寒いです surprise [ 2. 6. 36. 3. 52.] surprise そう @palinoia 彼らはイスラエル人と外国人のために事実を隠蔽する言葉を使い、無知を利用して広め、支持を得ようとしている #zionism sadness [ 1. 24. 2. 72. 2.] sadness ティムがいないとプールが楽しくない... joy [9.e-02 2.e+00 1.e+02 9.e-01 2.e+00] joy メライとバデットと一緒にプーケットを楽しんでいます。 anger [79. 2. 5. 10. 4.] anger 土曜の夜、私は仕事のことで激怒している。 #paperproblems #isitchristmasyet
不正解だったデータ
# "いいライブ", "嬉しい"に引きづられていると思われる joy [ 1. 3. 53. 39. 5.] sadness いいライブだったよ(о´∀`о) 寂しくなるわ @eshshanane. NYCはあなたがいてくれてラッキーだよ。嬉しい #ジョイ #ポンズ # "悲しみ"に引きづられていると思われる sadness [ 6. 14. 6. 68. 6.] joy 悲しみは人生に必要な悪である。 # 確率的にほぼ同等で微妙(言い方によってはangerとも取れる文なので難しい文と言える) disgust [ 2. 34. 15. 18. 32.] sadness @SaschaIllyvich 私の話をベータ版で読んでないってことかな?
不正解だったが真ラベルがおかしそうなデータ
# 言われてイライラするならangerが正しそう anger [86. 3. 2. 5. 3.] surprise ミドルネームを言われると イライラするの ママが怒るとそう呼ぶの # これでjoyが真ラベルとはどういうことかw surprise [ 2. 5. 23. 3. 67.] joy 私が一日中履いていたパンツが透けて見えることが判明しました。 # 明らかにsadnessな感じ sadness [ 1. 13. 18. 64. 4.] joy 世界中のお金があっても、息子のいる専業主婦ほど幸せにはなれません。
ざっと見ていくと以下のような傾向が見て取れる
- わかりやすい単語が入っている場合はそれによって判定している
- 嬉しい、悲しい、怒りとか
- 真のラベルとして複数の感情が取れそうなものがある
- 特にdisgustとsadness(嫌だし悲しい)
- 前後の文脈や言い方次第で真ラベルが変わるものがある
- そもそも真ラベルが間違ってそうなものが意外とある
- (ラベル数を減らすためにいくつかをマージしたので当たり前だが)
真ラベルと比較すると不正解だが、上記のような難しい文やそもそもおかしい文がけっこうあるので、一個ずつ見ていくとそれなりに合っているように見えるもの多い印象
まとめ
既存のデータセットを翻訳して使い、目視でのクリーニングなどもしないという比較的手抜きな方法だが、その割にはまあまあの精度になったと思われる。間違っているもののそうとも取れるみたいな文が結構あったので、実際に予測に使うとf1-scoreの割にはあってるように見える予測ができそう。
次は以下をやってみる(かもしれない)
- マルチラベル分類にする
- データのクリーニングをもっとちゃんとやる
- 少なくとも翻訳ミスで英語のままになっている文など明らかなものは排除すべき
- ラベル毎のデータ数のバランスをとる
- 評価用のデータとして目視で真ラベルをチェックしてデータセットを作ってみる
- 分類器の部分に隠れ層を増やすとどうなる?
- 学習済みモデルのパラメータはフリーズして学習したらどうなる?
参考
- https://github.com/abishekarun/Text-Emotion-Classification
- https://huggingface.co/transformers/model_doc/bert.html
- https://self-development.info/google%E7%BF%BB%E8%A8%B3%E3%82%88%E3%82%8A%E5%84%AA%E3%82%8C%E3%81%9Fdeepl%E7%BF%BB%E8%A8%B3%E3%81%A7%E8%87%AA%E5%8B%95%E7%BF%BB%E8%A8%B3%E3%80%90%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3/
- https://paperswithcode.com/paper/practical-text-classification-with-large-pre
- https://tlkh.github.io/text-emotion-classification/
- https://note.nkmk.me/python-sklearn-confusion-matrix-score/
- https://blog.ikedaosushi.com/entry/2019/05/27/102818
- https://gotutiyan.hatenablog.com/entry/2020/09/09/111840
- https://jun-networks.hatenablog.com/entry/2019/11/01/020536