ナード戦隊データマン

機械学習, 自然言語処理, データサイエンスについてのブログ

sentimentjaの作り方: 日本語の感情分析モデルを簡単作成

sentiment_jaは、日本語テキストの感情を6種類に分類するためのモデルです。このモデルを作るのはそれほど難しくはありませんし、ブログ記事の中で作り方を書いた覚えがありますが、「使い方しかわからん!」という人もいると思うので、sentiment_jaを作るためのすべてのコードを紹介します。

github.com

sentiment_jaの作り方

f:id:mathgeekjp:20191211145027j:plain

一般的な言葉で作り方を書くと以下のようになります。

  1. 目的の感情に対応した絵文字を使ってツイートを検索する。
  2. 検索したツイートを感情ごとに分けてスクレイピングする。
  3. スクレイピングした各々のツイートに対し、感情ごとに自動アノテーション
  4. ツイートテキストから絵文字を除去。
  5. モデルを作成。入力は絵文字除去したツイートテキスト、出力はアノテーションした感情ラベル。
  6. テストデータでテスト。(あいにく、私は一定の規模のテストデータを持っていません。)

コード

コード全体はgithubで公開しています。

git clone https://github.com/sugiyamath/sentiment_ja_train

基本的に、すべてのフェーズを一気に実行したい場合はmakeを実行するだけでできます。しかし、Makefileはテストしていないので、実行が失敗する可能性はあります。

ツイートのスクレイピング

twitterscraper( https://github.com/taspinar/twitterscraper )をインストールし、以下を実行します。

#!/bin/bash
twitterscraper 😄 --lang ja --limit 1000000 --csv --output happy.csv &
twitterscraper 😢 --lang ja --limit 1000000 --csv --output sad.csv &
twitterscraper 😲 --lang ja --limit 1000000 --csv --output surprise.csv &
twitterscraper 🤮 --lang ja --limit 1000000 --csv --output disgust.csv &
twitterscraper 😡 --lang ja --limit 1000000 --csv --output angry.csv &
twitterscraper 😨 --lang ja --limit 1000000 --csv --output fear.csv & 

wait

echo "Done!"

2019-12-10 13:42 時点では、twitterscraperの出力内のテキストからはデフォルトで絵文字が消えているため、テキストから絵文字を除去する処理を書く必要はありません。なお、twitterscraper内ではfree-proxyを使っていますが、スクレイピングの挙動に対して対策される恐れもあるので乱用は禁物です。

データのマージ

スクレイピングしたものに自動アノテーションし、一つのファイルとして結合します。

Note: 自動アノテーションと書いていますが、それぞれのcsvは感情ごとに分離されてスクレイピングしてあるので、すでにこの分離状態がアノテーションと同等です。

import os
import sys
import pandas as pd


if __name__ == "__main__":
    path = sys.argv[1]

    files = (os.path.join(path, x) for x in (
        "happy.csv", "sad.csv", "angry.csv",
        "disgust.csv", "surprise.csv", "fear.csv"
    ))

    dfs = []
    for label, fname in enumerate(files):
        df = pd.read_csv(fname, sep=";")
        df["label"] = label
        dfs.append(df)
    dfs = pd.concat(dfs)
    dfs.to_csv(os.path.join(path, "tweets.csv"))

データの均衡化

モデルは、各感情ごとにバランス良く予測したいため、均衡化します。

import pandas as pd
import random
import sys
import os


def fix(df):
    min_size = min(df[df["label"] == i].shape[0] for i in range(6))
    dfs = []
    for i in range(6):
        tdf = df[df["label"] == i][["text", "label"]]
        tdf = tdf.iloc[random.sample(range(0, tdf.shape[0]), k=min_size)]
        dfs.append(tdf)
    dfs = pd.concat(dfs)
    return dfs

if __name__ == "__main__":
    path = sys.argv[1]
    df = pd.read_csv(os.path.join(path, "tweets.csv"))
    df = fix(df)
    df.to_csv(os.path.join(path, "tweets_fixed.csv"))

テキストを抽出

sentencepieceのモデルを学習したいので、ツイートテキストを抽出しておきます。

import pandas as pd
import sys
import os
from tqdm import tqdm

if __name__ == "__main__":
    path = sys.argv[1]
    df = pd.read_csv(os.path.join(path, "tweets.csv"))["text"]
    with open(os.path.join(path, "tweet_texts.txt"), "w") as f:
        for text in tqdm(df):
            text = str(text).replace("\n", " ")
            f.write(text + "\n")

sentencepieceの訓練

sentencepiece( https://github.com/google/sentencepiece )をインストールし、テキストからbpeを訓練します。

spm_train --input=tweet_texts.txt --model_prefix=sp --vocab_size=8000 --model_type=bpe

モデルの訓練

tensorflowのkerasを使ってモデルを訓練します。(このモデルはsentencepieceの"モデル"とは別です。)

import os
import sys
import pandas as pd
from sklearn.utils import shuffle
import sentencepiece as spm
from tensorflow.keras.layers import Dense, Embedding, GRU
from tensorflow.keras.layers import SpatialDropout1D
from tensorflow.keras.layers import SeparableConv1D, MaxPooling1D
from tensorflow.keras.models import Sequential
from tensorflow.keras.callbacks import ModelCheckpoint
from tensorflow.keras.preprocessing.sequence import pad_sequences

sp = spm.SentencePieceProcessor()
sp.Load(os.path.join(sys.argv[1], "sp.model"))


def load(trainfile):
    df = shuffle(pd.read_csv(trainfile))
    df_train = df.iloc[:-1000]
    df_valid = df.iloc[-1000:]
    X_train = preprocess(df_train["text"])
    y_train = df_train["label"].astype(int).to_numpy()
    X_valid = preprocess(df_valid["text"])
    y_valid = df_valid["label"].astype(int).to_numpy()
    return X_train, X_valid, y_train, y_valid


def preprocess(texts, maxlen=300):
    return pad_sequences([sp.EncodeAsIds(text) for text in texts],
                         maxlen=maxlen)


def build_model(max_features=8000,
                max_len=300,
                dim=200,
                gru_size=100,
                dropout_rate=0.2,
                outsize=6):
    model = Sequential([
        Embedding(max_features + 1, dim, input_length=max_len),
        SpatialDropout1D(dropout_rate),
        SeparableConv1D(32, kernel_size=3, padding='same', activation='relu'),
        MaxPooling1D(pool_size=2),
        SeparableConv1D(64, kernel_size=3, padding='same', activation='relu'),
        MaxPooling1D(pool_size=2),
        GRU(gru_size),
        Dense(outsize, activation='sigmoid', kernel_initializer='normal')
    ])
    model.compile(loss='sparse_categorical_crossentropy',
                  optimizer='nadam',
                  metrics=['accuracy'])
    return model


def main(path):
    callbacks = [
        ModelCheckpoint(os.path.join(path, "model_best.h5"),
                        save_best_only=True,
                        monitor='val_loss',
                        mode='min')
    ]
    X_train, X_valid, y_train, y_valid = load(
        os.path.join(path, "tweets_fixed.csv"))
    epochs = 10
    batch_size = 1000
    model = build_model()
    model.fit(X_train,
              y_train,
              validation_data=(X_valid, y_valid),
              epochs=epochs,
              batch_size=batch_size,
              verbose=1,
              callbacks=callbacks)
    model.save(os.path.join(path, "model_last.h5"))


if __name__ == "__main__":
    main(sys.argv[1])

モデルを使う

以下のスクリプトを使ってモデルの挙動をテストします。

import os
import sys

import sentencepiece as spm
import tensorflow.keras as keras

model = keras.models.load_model(os.path.join(sys.argv[1], "model_best.h5"))
sp = spm.SentencePieceProcessor()
sp.load(os.path.join(sys.argv[1], "sp.model"))


def preprocess(texts, maxlen=300):
    return keras.preprocessing.sequence.pad_sequences(
        [sp.EncodeAsIds(text) for text in texts],
        maxlen=maxlen)


test_data = [
    "まじきもい、あいつ", "今日は楽しい一日だったよ", "ペットが死んだ、実に悲しい", "ふざけるな、死ね", "ストーカー怖い",
    "すごい!ほんとに!?", "葉は植物の構成要素です。", "ホームレスと囚人を集めて革命を起こしたい",
    "ファイナルファンタジーは面白い",
    "クソゲーはつまらん"
]

targets = preprocess(test_data)
emos = ["happy", "sad", "angry", "disgust", "surprise", "fear"]
for i, (ds, text) in enumerate(zip(model.predict(targets), test_data)):
    print(text)
    print('\t'.join(emos))
    print('\t'.join([str(round(100.0 * d)) for d in ds]))
    print()

[出力]

まじきもい、あいつ
happy   sad     angry   disgust surprise        fear
1.0     1.0     4.0     10.0    1.0     2.0

今日は楽しい一日だったよ
happy   sad     angry   disgust surprise        fear
39.0    1.0     1.0     2.0     2.0     0.0

ペットが死んだ、実に悲しい
happy   sad     angry   disgust surprise        fear
0.0     18.0    0.0     1.0     1.0     1.0

ふざけるな、死ね
happy   sad     angry   disgust surprise        fear
0.0     1.0     18.0    2.0     0.0     1.0

ストーカー怖い
happy   sad     angry   disgust surprise        fear
0.0     3.0     1.0     10.0    2.0     27.0

すごい!ほんとに!?
happy   sad     angry   disgust surprise        fear
1.0     1.0     1.0     3.0     40.0    9.0

葉は植物の構成要素です。
happy   sad     angry   disgust surprise        fear
6.0     0.0     6.0     6.0     1.0     1.0

ホームレスと囚人を集めて革命を起こしたい
happy   sad     angry   disgust surprise        fear
2.0     1.0     31.0    3.0     1.0     1.0

ファイナルファンタジーは面白い
happy   sad     angry   disgust surprise        fear
24.0    1.0     1.0     2.0     1.0     0.0

クソゲーはつまらん
happy   sad     angry   disgust surprise        fear
0.0     0.0     10.0    17.0    0.0     1.0

なぜこれがうまくいくのか

distant supervision

sentiment_jaは「絵文字をラベルとして訓練」していることに気がつくと思います。これは、前提として「絵文字は感情と対応していることが多いだろう」というざっくりした仮定を置いています。こうすると多少ラベルにノイズは含まれてしまうものの、以下の2つの利点が得られることになります:

  1. 大量のデータの自動的な獲得。
  2. 獲得したデータに対する自動的なアノテーション

このような手法はdistant supervisionと呼ばれます。この手法は、sentiment140 (http://help.sentiment140.com/) で用いられている手法とほとんど同じです。

テキストからは絵文字を消す必要があります。これはleakageを防ぐためです。テキスト内にラベルと等価な情報(絵文字)が存在すると、他の語を無視して絵文字だけを過大評価するようになり、汎化性能が下がります。汎化性能を上げるために、絵文字をテキストから消します。

modeling

データを獲得したあとは、少々の前処理を施して感情分析のモデリングを行うだけです。感情分析のモデリングの手法には以下のような方法の例があります:

ここで用いている手法は後者の手法です。

BPE

エンコードにはBPEが用いられており、そのためにsentencepieceというツールが使われています。BPEによるエンコーディングは、自然言語処理のタスクにおいて一般的な方法です。

なぜ「文字ベース」とか「単語ベース」の手法を使わないかというと、語彙数が増えてしまうためです。BPEは指定のサイズの語彙数まで圧縮できるため、モデルがよりコンパクトになり、頻度の低い語を切り捨てないで済みます。つまり、BPEは圧縮のための手法です。

BPE自体が訓練が必要ですが、教師なし学習で訓練することが可能です。特定のドメインの大きめのサイズのコーパスがあれば、それを入力にすればアルゴリズムが「サブワード」を学習してくれます。このコーパスにはアノテーションが必要がありません。純粋にテキストだけで学習できます。

sentencepieceの詳細はQiitaの以下の記事を見てください:

Sentencepiece : ニューラル言語処理向けトークナイザ - Qiita

CNN

BPEによってエンコードされたテキストは、Embedding, CNN, GRUというレイヤーを通じて出力層まで変換されます。EmbeddingとCNNを組み合わせて学習させる手法は、テキスト分類タスクにおいて一貫性のある予測結果が得られるようです。

Report on Text Classification using CNN, RNN & HAN - Jatana - Medium

また、CNNはGPUを使って訓練させると高速に訓練できます。

ちなみに、CNNに対して、さらにGRUというレイヤーを追加することを私は好みますが、これは何度か実験してみて経験的に良い結果が得られることが多かっただけです。GRUを使った場合は、速度的な面ではボトルネックになるかもしれません。

参考

  1. Sentiment140 - A Twitter Sentiment Analysis Tool
  2. Sentencepiece : ニューラル言語処理向けトークナイザ - Qiita
  3. Report on Text Classification using CNN, RNN & HAN - Jatana - Medium
  4. GitHub - taspinar/twitterscraper: Scrape Twitter for Tweets