BoW ✕ ロジスティック回帰で誰がツイートしたかを当てる

##環境
ubuntu 16.04 LTS
python 3.7.3
##やること
[Python機械学習プログラミング(第一版)](https://www.amazon.co.jp/Python%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0-%E9%81%94%E4%BA%BA%E3%83%87%E3%83%BC%E3%82%BF%E3%82%B5%E3%82%A4%E3%82%A8%E3%83%B3%E3%83%86%E3%82%A3%E3%82%B9%E3%83%88%E3%81%AB%E3%82%88%E3%82%8B%E7%90%86%E8%AB%96%E3%81%A8%E5%AE%9F%E8%B7%B5-impress-top-gear%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA-ebook/dp/B01HGIPIAK)の「第8章 | 機械学習の適用1-感情分析」を参考に、2人のユーザーによるツイートを正解ラベルありで学習させ、ツイートからどちらのユーザーがツイートしたかを判定します。ちなみにこの本は第二版が出ているので買う場合はそっちを選んでください。特に深層学習のトピックが増えているらしいです。
##学習するツイートについて
ともにライターで、ツイッターを利用していれば1度は見たことがあるであろう[ARuFa](https://twitter.com/ARuFa_FARu?lang=ja)さんと[ヨッピー](https://twitter.com/yoppymodel?lang=ja)さんのツイートを使います。
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">服屋で店員さんに声をかけられるのが怖いので、同じ根暗たちを集めて『店員に声をかけられる前に爆速で服を買い、その服だけでコーディネートをする大会』をした記事を書きました。緊張感すごい<br><br>【挑戦】店員に声をかけられる前に服を買え！「即買いコーディネート選手権」！<a href="https://t.co/ZuNp1vvNy7">https://t.co/ZuNp1vvNy7</a> <a href="https://t.co/ZtFcRXQ5za">pic.twitter.com/ZtFcRXQ5za</a></p>&mdash; ARuFa (@ARuFa_FARu) <a href="https://twitter.com/ARuFa_FARu/status/1145527761626255360?ref_src=twsrc%5Etfw">2019年7月1日</a></blockquote>
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">笹のコスプレ <a href="https://t.co/vIsbRdAyWT">pic.twitter.com/vIsbRdAyWT</a></p>&mdash; ヨッピー (@yoppymodel) <a href="https://twitter.com/yoppymodel/status/1147842087842246657?ref_src=twsrc%5Etfw">2019年7月7日</a></blockquote>

Twitter APIのstatuses/user_timelineでそれぞれツイートを取得し、ツイート内のユーザーIDとURLはここで取り除きます。ツイート、ユーザー(正解ラベル：0，1)のDataFrameにしてCSVで保存します。

ここでstatuses/user_timelineについて、指定したユーザーのツイートを新しい順に3200個取得するのですが、それにはRTも含まれます。include_rts=FalseでRTは除いているのですが、カウント時には含まれるので、例えばRTが200個あればレスポンスとして取得できるツイートは3000個になります。さらにその後の処理でリプライも除いているので、RTやリプライ数により結果として取得できるツイートは3200から大きく減る場合があります。

Twitter APIの概要やsearch/tweetsの使い方等は[こちら](https://qiita.com/h_tashiro/items/ed119c237f5595c3d7b8)

```python
import urllib
from requests_oauthlib import OAuth1Session, OAuth1
import requests
import sys
import re
import pandas as pd


def main():
    # APIの認証キー
    CK = 'xxxxxxxxxxxxxxxx'
    CKS = 'xxxxxxxxxxxxxxxx'
    AT = 'xxxxxxxxxxxxxxxx'
    ATS = 'xxxxxxxxxxxxxxxx'
    # ユーザーID
    user_id = 'ARuFa_FARu' # 正確にはscree_nameのこと。本来のuser_idは数字だけで表現される
    # 取得時のパラメーター
    range = 200 # 検索回数の上限値(最大200)
    # インスタンス作成
    get = Get_User_Timeline()
    save = Save_Data()
    # タイムライン取得
    tweets = get.get_tl(CK, CKS, AT, ATS, user_id, range)
    # 前処理
    tweets = get.preprocess(tweets)
    # DataFrameの作成
    user_label = 0 # ユーザーラベル
    df = save.to_DF(tweets, user_label)
    # CSVとして保存
    path = "ARuFa.csv"
    save.save_as_csv(df, path)


class Get_User_Timeline:

    def get_tl(self, CK, CKS, AT, ATS, user_id, range):
        user_id = urllib.parse.quote_plus(user_id)
        url = "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name="+user_id+"&include_rts=False&tweet_mode=extended"
        auth = OAuth1(CK, CKS, AT, ATS)
        response = requests.get(url, auth=auth)
        data = response.json()
        cnt = 0
        tweets = []
        count_tweets = 0
        while True:
            if len(data) == 0:
                break
            if cnt > range:
                break
            cnt +=  1
            for tweet in data:
                if tweet['in_reply_to_status_id'] == None:
                    tweets.append(tweet['full_text'])
                    count_tweets += 1
                max_id = int(tweet["id"]) - 1
            url = "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name="+user_id+"&include_rts=False&tweet_mode=extended&max_id="+str(max_id)
            response = requests.get(url, auth=auth)
            try:
                data = response.json()
            except KeyError:
                print('上限まで検索しました')
                break
        print('取得したツイート数 :', count_tweets)
        return tweets

    def preprocess(self, tweets):
        for i in range(len(tweets)):
            tweets[i] = re.sub(r'@\w+', '', tweets[i])
            tweets[i] = re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-]+', '', tweets[i])
        return tweets


class Save_Data():

    def to_DF(self, tweets, user_label):
        cols = ['tweet', 'user']
        df = pd.DataFrame(index=[], columns=cols)
        for tweet in tweets:
            record = pd.Series([tweet, user_label], index=df.columns)
            df = df.append(record, ignore_index=True)
        return df

    def save_as_csv(self, df, path):
        df.to_csv(path, index=False)


if __name__ == '__main__':
    main()
```
##テキストデータのベクトル化について
BoWというモデルでテキストをベクトル化し,さらにTF-IDFが適用されるsklearnのTfidfVectorizerを使用します。同じくsklearnのCountVectorizerのようなシンプルなBoWモデルでは、各テキストにおける単語の出現頻度をそのままベクトルに反映し、その出現頻度を

```math
tf(t,d)
```
で表します。dは各テキストで、tはその中の各単語の出現回数です。ここで、「が」「は」といったような助詞など多くのテキストに頻繁に出現するような単語は判断材料として乏しいので過剰な重みを与えないようにしたいと考え、IDFを次にように定義します。

```math
idf(t, d) = log\frac{n_d}{1 + df(t, d)}
```
nはテキストの総数です。単語の出現頻度が大きいほど重みが抑えられます。また、出現頻度が低い単語にも過剰な重みが与えられることを防ぐために対数をとっています。そして、TF-IDFはtfとidfの積で定義されます。

```math
tfｰidf(t, d) = tf(t, d) × idf(t, d)
```
さらにこの後の学習では、ベクトルを正規化するべきかの検証も行われます。詳しいことは参考文献を読んでください。
##データの前処理
さきほど作成したCSVをそれぞれ読み込み、欠損値のある列を削除します。ここからは全てjupyter-notebookで実行しています。

```python
import pandas as pd

df_y = pd.read_csv('Yoppi.csv')
df_a = pd.read_csv('ARuFa.csv')
df_y = df_y[~df_y['tweet'].isnull()]
df_a = df_a[~df_a['tweet'].isnull()]
```
ここでデータをチェックしてみます。

```python
print('ヨッピー')
print(df_y.info())
print(df_y.head(5))
```
```
ヨッピー
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1198 entries, 0 to 1205
Data columns (total 2 columns):
tweet    1198 non-null object
user     1198 non-null int64
dtypes: int64(1), object(1)
memory usage: 28.1+ KB
None
                                               tweet  user
0  【定期】ヨッピーが書いた記事が全部届くLINE@（スマホから）はこっち→ 　たまにダラダラ長...     0
1  坦坦担麺\n\n元RADWIMPSの斉木さん、ギターを置いてなぜ汁なし担々麺屋になったんですか？      0
2  Twitterで見かけたあの件、マジだったのか……！\n\nお悔やみ欄見て遺族に虚偽請求の手...     0
3  「ハイヒール履いてみた」って動画が話題だけど、自分でやってみて当事者のしんどさを知るっていう...     0
4  【定期】ヨッピーが書いた記事が全部届くLINE@（スマホから）はこっち→ 　たまにダラダラ長...     0
```
```python
print('ARuFa')
print(df_a.info())
print(df_a.head())
```
```
ARuFa
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1952 entries, 0 to 1971
Data columns (total 2 columns):
tweet    1952 non-null object
user     1952 non-null int64
dtypes: int64(1), object(1)
memory usage: 45.8+ KB
None
                                               tweet  user
0  服屋で店員さんに声をかけられるのが怖いので、同じ根暗たちを集めて『店員に声をかけられる前に爆...     1
1  周りのタピオカユーザーに差をつけたいので、クッソでかいタピオカを吸ってるように見えるストロー...     1
2                             ムチャクチャな理由で会社が休みになりました      1
3  最近カレーばっかりで飽きてきたので、カレーを構成する『カレールー』『白米』『スプーン』の位置...     1
4                                    ？？？？？？？？？？？？？？      1
```
ヨッピーさんのツイート数が1198に対してARuFaさんは1952です。前述の通り、statuses/user_timelineの仕様及びリプライを除いていることからRT数やリプライ数が多いほどここでのツイート数は少なくなります。この差に関する検証は後ほどやっていきたいと思います。
またヨッピーさんの方は定期ツイートが設定されているのでこれも除きます。

```python
df_y = df_y[df_y['tweet']!='【定期】ヨッピーが書いた記事が全部届くLINE@（スマホから）はこっち→ 　たまにダラダラ長文を投下するFB（5000人到達したからフォローしてね）はこっち→']
df_y.info()
```
```
<class 'pandas.core.frame.DataFrame'>
Int64Index: 898 entries, 1 to 1205
Data columns (total 2 columns):
tweet    898 non-null object
user     898 non-null int64
dtypes: int64(1), object(1)
memory usage: 21.0+ KB
```
さらに300ほど少なくなりました。

最後に、2つのDataFrameを縦に結合し、学習・テストデータに分割します。

```python
df = pd.concat([df_y, df_a])
X_train, X_test, Y_train, Y_test = train_test_split(df.tweet, df.user, test_size=0.3, random_state=0)
```
##ロジスティック回帰で学習させる
Pipelineでパラメータの比較をまとめて行い、先ほど触れたベクトルの正規化についても検証します。基本的に元のプログラムと同じですが、TfidfVectorizerのtokenizerとstop_wordsは変えています。

①tokenizerについて、これはテキストをトークンごとに分割する処理です。英語の場合は素直に単語を空白で区切ればOKですが、日本語ではMeCabの分かち書きで分割する場合が多く、ここでもそうしています。また元のプログラムでは単純な単語の分割に加えて単語を原形に変換した場合（例:runnner, running, run → run）も検証していますが、ここでそのような処理は行いません。

②stop_wordsについて、これはどんなテキストにも出現しうるありふれた単語のことで、英語の場合isやandなどのことを指します。これを除く処理はNLTKというライブラリを使えば簡単に実装できるのですが、日本語は用意されていません。ただし、TF-IDFを適用した段階でストップワードのような単語の重みはあらかじめ抑えられているので、ストップワードを除く必要性はあまりないと参考文献でも言及されています。これについては後で検証しますので、ここではストップワードを除かずそのまま学習させます。

```python
import MeCab
import sys
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.feature_extraction.text import TfidfVectorizer

def tokenizer(text):
    m = MeCab.Tagger('-Owakati')
    wakati_text = m.parse(text)
    return wakati_text.split()

tfidf = TfidfVectorizer(strip_accents=None,
                        lowercase=False,
                        preprocessor=None)
param_grid = [{'vect__ngram_range': [(1, 1)],
               'vect__stop_words': [None]
               'vect__tokenizer': [tokenizer],
               'clf__penalty': ['l1', 'l2'],
               'clf__C': [1.0, 10.0, 100.0]},
             {'vect__ngram_range': [(1, 1)],
               'vect__stop_words': [None]
               'vect__tokenizer': [tokenizer],
               'vect__use_idf': [False],
               'vect__norm': [None],
               'clf__penalty': ['l1', 'l2'],
               'clf__C': [1.0, 10.0, 100.0]}
             ]
lr_tfidf = Pipeline([('vect', tfidf),
                     ('clf', LogisticRegression(random_state=0))])
gs_lr_tfidf = GridSearchCV(lr_tfidf, param_grid,
                           scoring='accuracy',
                           cv=5, verbose=1, n_jobs=-1)

gs_lr_tfidf.fit(X_train, Y_train)
```
##実行結果
```python
gs_lr_tfidf.best_params_
# {'clf__C': 100.0,
# 'clf__penalty': 'l2',
# 'vect__ngram_range': (1, 1),
# 'vect__stop_words': None,
# 'vect__tokenizer': <function __main__.tokenizer(text)>}
```
```python
gs_lr_tfidf.best_score_
# 0.8751879699248121
```
```python
clf = gs_lr_tfidf.best_estimator_
clf.score(X_test, Y_test)
# 0.8783625730994152
```
best_params_で最適なパラメータが見れます。まずTfidfVectorizerについて、TF−IDFが適用され、ベクトルにはL2正則化(デフォルト)が適用されています。次にLogisticRegressionについて、L2正則化が適用され、その正則化項のパラメータCは100.0となっています。下の3つは共通です。

学習データでの正解率は約87.5%、テストデータでは約87.8%です。参考文献では学習・テストデータがそれぞれ25000個あったので足りるかなと思ったのですが、割といい感じになりました。

参考文献の内容としてはここまでですが、以降はこれまでに触れた今回の分析で気になる2つの点について検証していきます。
##データの偏りについて
前処理後のデータ数は、ヨッピーさんが898、ARuFaさんが1952と倍近い偏りがあります。一般的にこの場合はARuFaさんと判定される確率の方が高くなると考えられ、実際に先ほどの学習結果についてテストデータをヨッピーさんとARuFaさんで分けて検証してみると、

```python
X_y_test = X_test[Y_test==0]
Y_y_test = Y_test[Y_test==0]
X_a_test = X_test[Y_test==1]
Y_a_test = Y_test[Y_test==1]
```
```python
clf.score(X_y_test, Y_y_test)
# 0.7509157509157509
```
```python
clf.score(X_a_test, Y_a_test)
# 0.9381443298969072
```
ヨッピーさんのツイート正しく判定できる確率は約75.1％、ARuFaさんのツイートを正しく判定できる確率は約93.8％と、20％近い差がありました。

このような偏りを前処理の段階で抑えるには、基本的にデータを削るか補うかの2択になると思います。数値データであれば平均値などで補えますが、テキストデータでは難しく、今回は少ない方でも約900個あるのでそれに合わせて大きい方のデータを削ります。

```python
df_a2 = df_a.sample(n=len(df_y), random_state=0)
df = pd.concat([df_y, df_a2])
X_train, X_test, Y_train, Y_test = train_test_split(df.tweet, df.user, test_size=0.3, random_state=0)
```
ヨッピーさんのデータの数だけARuFaさんのデータからランダムに抽出し、それを結合して学習・テストデータに分割しています。

あとは先程と同じように学習させるので、コードは省略します。
###実行結果
```python
gs_lr_tfidf.best_params_
# {'clf__C': 100.0,
# 'clf__penalty': 'l2',
# 'vect__ngram_range': (1, 1),
# 'vect__stop_words': None,
# 'vect__tokenizer': <function __main__.tokenizer(text)>}
```
```python
gs_lr_tfidf.best_score_
# 0.8480509148766905
```
```python
clf = gs_lr_tfidf.best_estimator_
clf.score(X_test, Y_test)
# 0.862708719851577
```
データ数を減らしたためか、学習データでは約2.7％・テストデータでは約1.6％正解率が下がっています。

続いて、テストデータをヨッピーさんとARuFaさんで分けて検証してみましょう。

```python
X_y_test = X_test[Y_test==0]
Y_y_test = Y_test[Y_test==0]
X_a_test = X_test[Y_test==1]
Y_a_test = Y_test[Y_test==1]
```
```python
clf.score(X_y_test, Y_y_test)
# 0.8717948717948718
```
```python
clf.score(X_a_test, Y_a_test)
# 0.8533834586466166
```
2つの正解率の差を約1.8％まで縮めることができました。どのような課題かにもよるとは思いますが、データ数の偏りは抑えるべきだと確認できましたね。次の検証でも同じようにデータ数は揃えます。
##ストップワードについて
元のプログラムと今回のプログラムの違いとして、stop_wordsを除くかどうかの検証について説明しました。
>stop_wordsについて、これはどんなテキストにも出現しうるありふれた単語のことで、英語の場合isやandなどのことを指します。これを除く処理はNLTKというライブラリを使えば簡単に実装できるのですが、日本語は用意されていません。ただし、TF-IDFを適用した段階でストップワードのような単語の重みはあらかじめ抑えられているので、ストップワードを除く必要性はあまりないと参考文献でも言及されています。これについては後で検証しますので、ここではストップワードを除かずそのまま学習させます。

ということで、ここではストップワードを除く場合も検証してみます。前述のようにNLTKのストップワードには日本語のものはないので、
http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt
をstopwords.txtとしてダウンロード（コピペ）します。これは京都大学が作成した[SlothLib](http://www.dl.kuis.kyoto-u.ac.jp/slothlib/)というライブラリに登録されている単語ベースによるストップワードです。分かち書きで分割された単語のうちこのストップワードに含まれるものを除きます。今回はTfidfVectorizerのパラメータstop_wordsはNoneのままで、tokenizerにストップワードを除くものを追加します。

```python
with open('stopwords.txt', 'r') as f:
    stopwords = []
    line = f.readline()
    while line:
        if line != '\n':
            stopwords.append(line.strip())
        line = f.readline()

def tokenizer(text):
    m = MeCab.Tagger('-Owakati')
    wakati_text = m.parse(text)
    return wakati_text.split()

def tokenizer_not_stopwords(text):
    m = MeCab.Tagger('-Owakati')
    wakati_text = m.parse(text)
    wakati_list = wakati_text.split()
    drop_words = set(wakati_list) & set(stopwords)
    return list(set(wakati_list) - drop_words)
```
また、tokenizerとtokenizer_not_stopwordsをまとめて検証するためparam_gridを以下のように変更します。

```python
param_grid = [{'vect__ngram_range': [(1, 1)],
               'vect__stop_words': [None],
               'vect__tokenizer': [tokenizer, tokenizer_not_stopwords], # 追加
               'clf__penalty': ['l1', 'l2'],
               'clf__C': [1.0, 10.0, 100.0]},
             {'vect__ngram_range': [(1, 1)],
               'vect__stop_words': [None],
               'vect__tokenizer': [tokenizer, tokenizer_not_stopwords], # 追加
               'vect__use_idf': [False],
               'vect__norm': [None],
               'clf__penalty': ['l1', 'l2'],
               'clf__C': [1.0, 10.0, 100.0]}
             ]
```
###実行結果
```python
gs_lr_tfidf.best_params_
# {'clf__C': 10.0,
#  'clf__penalty': 'l2',
#  'vect__ngram_range': (1, 1),
#  'vect__stop_words': None,
#  'vect__tokenizer': <function __main__.tokenizer_not_stopwords(text)>}
```
```python
gs_lr_tfidf.best_score_
# 0.863961813842482
```
```python
clf = gs_lr_tfidf.best_estimator_
clf.score(X_test, Y_test)
# 0.8589981447124304
```
パラメータはtokenizer_not_stopwordsの方になっていますが、正解率には最初と比べて有意な差は見られません（先ほどの検証を踏まえARuFaさんのデータ数を削っているので若干下がっています）。TF-IDFを適用する場合、やはりストップワードを除く必要性は低そうですね。

以上で終わりです。ありがとうございました！