日本語のテキスト感情分類をやってみる

感情分類は以下の2通りに大分けされる模様

  1. positive/negativeの二値分類(neutralを含める場合もあり)
  2. 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/&lt;/</g' -e 's/&gt;/>/g' -e 's/&amp;/\&/g' -e "s/&quot;/'/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/&lt;/</g' -e 's/&gt;/>/g' -e 's/&amp;/\&/g' -e "s/&quot;/'/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

結果がこちら f:id:y-kamiya:20210117111053p:plain

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に上げた混同行列はこんな感じになって見やすい f:id:y-kamiya:20210117111718p:plain

やはりデータ数の多い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の割にはあってるように見える予測ができそう。

次は以下をやってみる(かもしれない)

  • マルチラベル分類にする
    • どちらとも取れるようなデータに対応できるためよさそう
    • semeval2018というデータセットはマルチラベルのデータセットなのでそれを使ってみる(データ数は今回よりかなり減るが)
  • データのクリーニングをもっとちゃんとやる
    • 少なくとも翻訳ミスで英語のままになっている文など明らかなものは排除すべき
    • ラベル毎のデータ数のバランスをとる
    • 評価用のデータとして目視で真ラベルをチェックしてデータセットを作ってみる
  • 分類器の部分に隠れ層を増やすとどうなる?
  • 学習済みモデルのパラメータはフリーズして学習したらどうなる?

参考