LSTM + Embeddingで、ツイートの感情を判別するモデルを作る中で、NLPにおける前処理の重要性を再認識した。

# モチベーション

色々とご縁があり、「生徒の回答が、金賞、銀賞、銅賞、圏外なのか？」を分類する、Text Classificationの問題を解くことになりました。

実務で、自然言語処理のタスクは、あまり解いた事がなかったので、とりあえずKaggleで盛り上がっていた「Sentiment Analayis 感情分析」の問題を解いてみて、練習する事にしました。

色々と試行錯誤する中で、(他のタスク以上に)自然言語処理のタスクでは、前処理(テキストの加工)が重要な事がわかったので、本記事にまとめようと思います。

##### データの概要説明
世界中の学生や実務家が、その実力を競い合っている、Kaggleというオンラインデータ分析コンペのサイトから、”Sentiment140 dataset with 1.6 million tweets”というデータセットが、共有されているので、そちらのデータを使用します。

こちらは、コンペティションで使用されたデータではなく、「感情分析に、是非役立ててください」と、Kaggleの有志が共有してくれたデータセットです。160万ものツイートが揃っており、かなりリッチなデータセットです。下記、データの概要を表で、共有します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/383b3bb0-bc9d-be7e-8a6d-37bc0679e9a1.png)
【データセットは、こちらから】：https://www.kaggle.com/kazanova/sentiment140

タスクは、text(tweet)が与えられた時、そのtweetがPositiveか？Negativeか?を予測する、分類問題(Binary Classification)の問題です。

##### モデリングの戦略
「モデルのアーキテクチャは固定させ、前処理を色々と変える」という方法を採用しました。
(本当は、色々試したのですが)本記事では、わかりすさを優先して、下記のようなフローで、簡潔に共有させて頂きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/9bdf0e8e-17e8-27d4-354f-c273db3ff4df.png)

Vanila LSTMとは、もっとも基本的(古典的)な、LSTMのストラクチャーで、下記の特徴を備えたモデルです。
1. 一つの隠れ層(LSTM Unit層)が一つ
2. 出力のための全結合層(Dense Layer)が一つ

コード(Keras Sequential API)で示すと、下記のようになります。

```python
model = Sequential()
model.add(LSTM(32, activation='tanh', input_shape=(n_steps, n_features)))
model.add(Dense(2,activation='softmax'))
model.compile(loss = 'categorical_crossentropy', optimizer='adam',metrics = ['accuracy'])
```
[参考:https://machinelearningmastery.com/how-to-develop-lstm-models-for-time-series-forecasting/]

さて、下記から、実際に行った実装について、共有していきます。

##### まずは、必要なライブラリと、データのインポート
```python
from collections import defaultdict, Counter
import time
import re
import string
import pandas as pd

import nltk
from nltk.corpus import stopwords 
from keras.preprocessing.sequence import pad_sequences
from keras.preprocessing.text import one_hot
from sklearn.model_selection import train_test_split

from keras.models import Sequential
from keras.layers import Embedding,LSTM, Dense
from keras.callbacks import EarlyStopping

df = pd.read_csv("twitter_sentiment_kaggle.csv", encoding="latin-1",header=0, 
                 names=['sentiment','id','date','flag','user','text'], usecols=["id", "sentiment", "text"])
print(df.head(2))
```
ここは、特に説明不要だと思いますので、次に行きます。

##STEP1: ツイートを綺麗にする前処理 version1
ツイートを綺麗にするとはどういうことか？というと、「学習の妨げになる、文字や記号を削除する」作業のことです。

今回のタスクは、モデルが「ツイートから、感情を読み取る(紐ずける)」ことをしないといけなので、「@username」や「URL」の情報は、邪魔になるでしょう。

NLPのタスクでは、そういった、「モデルに学習させたくない記号や文字を削除する作業」を、一般に「前処理」と名付けています。

さて、今回私が最初に試した、前処理を下記に記載しました。特別なものはなく、どれも基本的な前処理です。

【**実施した前処理**】
1. 単語を全て小文字にする
2. URLを削除する
3. 絵文字をエンコードする
4. モデリングに不必要な記号を、空白""に、置き換える
        - @usernames
        - 数字とアルファベット以外
        - 3つ以上連続している文字を2つに省略する　( e.g. "awwwww" ---> "aww" )
5. ツイートをトークン化し、ストップワードと句読点を削除する


```python
def clean_text(text_data):  
    # エンコーディング、正規表現、ストップワード、句読点の定義 ---> ネットで有志が定義してくれてるので、拝借しましょう。
    URL_PATTERN = r"((http://)[^ ]*|(https://)[^ ]*|( www\.)[^ ]*)"
    
    EMOJI_ENCODER_DICT = {':)': 'smile', ':-)': 'smile', ':))': 'smile', ';d': 'wink', ':-E': 'vampire', ':(': 'sad', ':-(': 'sad', ':-<': 'sad', ':P': 'raspberry', ':O': 'surprised',
                          ':-@': 'shocked', ':@': 'shocked',':-$': 'confused', ':\\': 'annoyed', ':#': 'mute', ':X': 'mute', ':^)': 'smile', ':-&': 'confused', 
                          '$_$': 'greedy','@@': 'eyeroll', ':-!': 'confused', ':-D': 'smile', ':-0': 'yell', 'O.o': 'confused','<(-_-)>': 'robot', 'd[-_-]b': 'dj', 
                          ":'-)": 'sadsmile', ';)': 'wink', ';-)': 'wink', 'O:-)': 'angel','O*-)': 'angel','(:-D': 'gossip', '=^.^=': 'cat'}
    
    USER_NAME_PATTERN = r'@[^\s]+'
    NON_ALPHA_PATTERN = r"[^A-Za-z0-9]"
    
    SEQUENCE_DETECT_PATTERN = r"(.)\1\1+"
    SEQUENCE_REPLACE_PATTERN = r"\1\1"
    
    ENGLISH_STOPWORDS = stopwords.words('english') 
    PUNCTUATIONS = string.punctuation.split()
    
    ############################### ツイートの前処理を実施する ########################################
    clean_tweets = []
    for each_tweet in text_data:
        # 文字を全て、小文字にする
        each_tweet = each_tweet.lower()
        
        # URLを消去する
        each_tweet = re.sub(URL_PATTERN, "", each_tweet).strip()
        
        # 3つ以上連続している文字を2つに省略する
        each_tweet = re.sub(SEQUENCE_DETECT_PATTERN, SEQUENCE_REPLACE_PATTERN, each_tweet)
        
        # 絵文字をエンコードする
        for key in EMOJI_ENCODER_DICT.keys():
            each_tweet = each_tweet.replace(key, " EMOJI " + EMOJI_ENCODER_DICT[key])
        
        ###　モデリングに必要ない、各種記号を削除する ###
        # ”@usernames”を削除する
        each_tweet = re.sub(USER_NAME_PATTERN, "", each_tweet)
        
        # 数字とアルファベット以外を削除する
        each_tweet = re.sub(NON_ALPHA_PATTERN, " ", each_tweet)
        
        ### ツイートをトークン化(要素が各単語のリスト、にする)し、ストップワードと句読点を削除する ###
        tokenizer = nltk.TweetTokenizer(preserve_case=False, strip_handles=True,  reduce_len=True)
        tweet_tokens = tokenizer.tokenize(each_tweet)

        #  ストップワードと句読点を削除
        clean_tweet_sentence = ' '
        for word in tweet_tokens: # 1単語ずつ見ていく
            if (word not in ENGLISH_STOPWORDS and  word not in PUNCTUATIONS):
                clean_tweet_sentence += (word+' ')
                
        clean_tweets.append(clean_tweet_sentence)
    return clean_tweets
#########################################################################################
# ツイートを綺麗にする
t = time.time()
clean_tweets_list = clean_text(df["text"])
print(f'ツイートが綺麗になりました。')
print(f'コード実行時間: {round(time.time()-t)} seconds')

# 綺麗にしたツイートを、'clean_tweet'として、新たな列に追加する
df["clean_text"] = clean_tweets_list

# 結果の表示
print(df[["text", "clean_text"]].head(2))

```
###### 綺麗にした後の結果： URLや@Usernameが適切に取り除かれています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/04957867-bd4c-e957-f26c-90c071f8205d.png)


## Step2: 文字をエンコードする
上記のSTEP1で、ツイートを綺麗にした後は、ツイートをモデルに学習させるための処理をします。つまり、ツイートに含まれるそれぞれの文字(英単語)を、数字で表します。モデルは、数字しか認識できないので、当然必要になってくる作業ですよね。

具体的に行った作業を下記にまとめます。

1. **ツイートに含まれる文字をそれぞれ、ハッシュ化(One Hot Encoding)する**
    - ハッシュ化とは、「文字一つ一つにインデックス番号を割り当てる」ことです。
    - 例えば、「I love LSTM」が、[100, 240, 600]に変換されます。
    - この前処理は、のちに後述するEmbedding関数を正しく実行する際に、必要になります。
    - また、keras one_hot関数で入力を要求される、引数nとは"語彙数"のことです。
    - 今回、"語彙数"nは、clean_tweetの語彙（Number of unique words）の数にしました。
    - 例えば、clean_tweetの語彙の数が、10万後なら、n=100000と入力します。
    - この語彙数nは、Embedding関数の引数である、input_dimと同じに設定するのが通常のpracticeです。
    - keras.preprocessing.text.one_hot関数についてのドキュメント:https://keras.io/ja/preprocessing/text/

2. **ハッシュ化されたツイートに対して、Padding/Truncatingを施す**
    - Padding/Truncatingとは、簡潔にいうと、「リストの要素数を統一する」ということです。
    - 例えば、「I love LSTM」と「I prefer GRU over LSTM」という、二つのツイートが、[100, 240, 600] と「100,250,900,760,600」にそれぞれ、ハッシュ化されたとします。
    - これだと、要素数(Sequenceの数)が合わないので、LSTMでの学習が困難になります。
    - なので、要素数を、指定した数(例えば、5つ)に揃えるために、Padding/truncatingをする必要があるのです。
    - Paddingは、指定した数(e.g.5つ)に足りなければ、0で穴埋めする、というものです。I love LSTMの例で例示すると、[100, 240, 600, 0, 0] もしくは [0, 0, 100, 240, 600]になります。
    - truncatingは、指定した数(e.g.3つ)を上回っていたら、余分な要素を削除する、というものです。I prefer GRU over LSTMの例で例示すると、「100,250,900」もしくは「900,760,600」になります。
    - keras.preprocessing.sequecne.pad_sequences関数についてのドキュメント:https://keras.io/ja/preprocessing/sequence/

```python
def encode_with_oneHot(text, total_vocab_freq, max_tweet_length):
    # One HotエンコーディングとPadding/Truncatingを実施する
    encoded_tweets_oneHot = []
    for each_tweet in text:
        each_encoded_tweet = one_hot(each_tweet, total_vocab_freq)
        encoded_tweets_oneHot.append(each_encoded_tweet)
    each_encoded_tweets_oneHot_pad = pad_sequences(encoded_tweets_oneHot, maxlen=max_tweet_length, 
                                                   padding="post", truncating="post")
    return each_encoded_tweets_oneHot_pad
###################################################################################################
### 綺麗にしたツイートをエンコーディングする ###
# 単語の出現回数を把握する
vocab_dict = defaultdict(int)
for each_t in df["clean_text_after_others"]:
    for w in each_t.split():
        vocab_dict[w] += 1
total_vocab_freq   = len(vocab_dict.keys())# 総単語総数のカウント

# 文章の長さを把握する
sentence_length_dict = defaultdict(int)
for i, each_t in enumerate(df["clean_text_after_others"]):
    sentence_length_dict[i] = len(each_t.split())
max_tweet_length = max(sentence_length_dict.values())# 一番長い文章のカウント

# 実行
t = time.time()
one_hot_texts = encode_with_oneHot(df["clean_text"], total_vocab_freq, max_tweet_length)
print(f'ツイートのワンホット・エンコーディングが終わりました')
print(f'コード実行時間: {round(time.time()-t)} seconds')
```
###### 実行結果：ツイートが、一行一行、それぞれ適切にハッシュ化されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/c4aac048-6249-5d48-9cea-8edf4a59d816.png)

# STEP3: モデリング
STEP２で、ツイートをハッシュ化(数値化=One Hotエンコーディング化)できたので、モデリングの準備が整いました。
下記に、コードの共有をします。

```python
embedding_length = 32
model = Sequential()
model.add(Embedding(input_dim=total_vocab_freq+1, output_dim=embedding_length, input_length=max_tweet_length, mask_zero=True))
model.add(LSTM(units=32))
model.add(Dense(2,activation='softmax'))
model.compile(loss = 'categorical_crossentropy', optimizer='adam',metrics = ['accuracy'])
print(model.summary())
```

先程"モデリングの戦略"でお示ししたように、これは古典的なLSTMのストラクチャーである、"Vanilla LSTM"です。

ただ、LSTM層の前に、Embedding layerを付けました。なぜかというと、ハッシュ化されたツイートだけでは、Semanticな意味を読み取ることができないからです。

この、Embeddingは、ハッシュ化させた単語をkeyとして、任意の次元の行列を返す関数です。この行列のそれぞれの要素には、semanticな意味が、付与されています。

つまり、どういうことか?というと、単語同士で、「王様-男+女=女王」のような演算が可能になるのです。
大事なので繰り返しますが、単語一つ一つに、行列表現(Semantic Meaning)が付与されたので、このような演算が可能になります。

これで、単語同士の関連性を、LSTMが学習できるようになりました。

Embeddingについては、@9ryuuuuuさんの記事がとてもわかりやすいので、ぜひ参考にしてみてください。https://qiita.com/9ryuuuuu/items/e4ee171079ffa4b87424

では、データの整形をして、モデルの学習を開始します。

```python
#データの整形
y = pd.get_dummies(df["sentiment"]).values
X_train, X_test, y_train, y_test = train_test_split(one_hot_texts, y, test_size = 0.2, random_state = 123, stratify=y)
print(X_train.shape,y_train.shape)
print(X_test.shape,y_test.shape)

#学習開始
batch_size = 256
callback = [EarlyStopping(monitor='val_loss', patience=2,  verbose=1)]
hist = model.fit(X_train, y_train, epochs=5, batch_size=batch_size, callbacks=callback, verbose=1, validation_split=0.1)

# 検証データに対する精度を表示
import numpy as np
print("Validation Accuracy:",round(np.mean(hist.history['val_accuracy']), 4))
```

# 検証データに対する精度の確認
78%でした。この数値は、kaggleの他のDL実装勢と、同じくらいの精度です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/bb587a28-3644-9544-55a0-402bf2cfb20e.png)

tf-idf+n-gramsをインプットに使用した、機械学習勢は、もう少し精度が劣ります。
観察の範囲だと、だいたい68~78%くらい。

なので、このモデルだと、だいたい、偏差値55~60くらいの成績です。(推測)
【参考：https://www.kaggle.com/kazanova/sentiment140/notebooks】

ここから、モデルのアーキテクチャーは変更せずに、前処理だけをアップデートして、再度実装を試みます。

改善点は、大きく二つありました。

# 改善点1 
改善点の一つ目は、「Stop Words」の扱いです。
Stop Wordsとは、例えば、「not」「no」「up」などの単語のことで、自然言語処理の世界では、慣例的に消去されることが多い、単語群のことです。
ただ、下記の例のように、"no"を削除した結果、ツイートの意味が大きく異なってしまう、という現象が発生してしまったので、stop wordsの削除を行わない、前処理に変更しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/3118bf33-9c7e-997e-5986-c24f36d13b84.png)

# 改善点2
二つ目の改善点は、「ツイートの単語数」についてです。
前処理後に、ツイートが、「play」だけになってしまった、みたいな一単語のみのツイートが存在するので、そのようなデータは削除しました。

# 改善後の結果
精度、上がりました。検証データに対して、82%の結果です。
これは、DL実装上位勢くらいの結果(偏差値60~65くらい?)なので、嬉しいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/b7daf61f-1707-2ee8-db40-70f491054dd0.png)


# 今後の方針
ブロンズマスターやグランドマスターレベルになると、88~92%くらいの成績を叩き出しているので、
まだまだ、私にも、改善余地があります。グランドマスターの方は、CNN+LSTMで実装している方が多いなぁと思いました。

ただ、自分は、このタスクでは、前処理を徹底的にする方針で、十分精度が9割に到達すると思っています。

なぜなら、まだまだ、前処理で改善しなければいけない箇所がたくさんあるからです。
例えば、下記のような前処理です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/c4c5a7fa-6be0-c0b5-ebca-b27d114b1434.png)

でもこれ、実は結構難しんですよね。
ツイート一つ一つに対して、英語か？の判定をしないといけないのですが、判定してくれるライブラリの精度が、すこぶる悪くて、使い物にならないのです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/865631/58639824-5011-661e-f656-b9ee6dcbbbfb.png)

これ、出力されているのが、langdetect.detect関数で、「英語ではない」と判定された文章なのですが、明らかに英語である文章が含まれています。

なので、automationが難しく、この前処理に関して、どうすればいいか？保留中です。
kagglerもこの点が難しいと感じてるのか、（私の観察の範囲では）誰もこの前処理をやってないです。

なので、引き続き、検証を続けて、記事をアップデートできればいいなぁと思っております。

ここまで、見ていただき、ありがとうございました。