こんにちは。ライブラリのAPI叩くマンの DSひとり@factorydatamngです。
ところで、私はツイッターで@nardtreeさんと南極にゃんこ@NekoAntarcticaさんをフォローしているのですが、このお二人はキャラがやや被っているうえ、どちらもアニメアイコンで、しかもアイコン変更の頻度が多いという共通点があります。
フォローして間もない頃はこれはどっちのツイートだ?と迷う事がよくありました。
今ではキャラの違いも分かってきて混同する事も無くなったのですが、ふと、これ機械学習で分類できるんじゃね? と思いたちました。
以下、勢いでやってみましたが、自然言語処理は素人なのでおかしな点があればコメント頂けるとありがたいです。(あと、お二方ネタにしてすみません😅)
この流れでやっていきます。
# 各種ライブラリのインポートなど
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import sys
import MeCab
from gensim import corpora, matutils
%matplotlib inline
plt.style.use('ggplot')
ツイッターのAPIキーを取得して…というのが正攻法でしょうが、ネタ企画なので今回は手抜き。
TwimeMachine でツイートを取得し、テキストファイルに保存します。
# テキストを行毎に分割
def tw_list_from_text(filepath):
f = open(filepath)
text = f.read()
f.close()
return text.split('\n')
nardtree_list = tw_list_from_text('nardtree.txt')
NekoAntarctica_list = tw_list_from_text('NekoAntarctica.txt')
print("nardtree: {0} tweets, last: {1}".format(len(nardtree_list), nardtree_list[0]))
print("南極にゃんこ: {0} tweets, last: {1}".format(len(NekoAntarctica_list), NekoAntarctica_list[0]))
nardtree: 3189 tweets, last: カフェアサンの近くのガスストーブ https://t.co/7PpECLl1LR Fri Mar 23 08:52:19 +0000 2018 南極にゃんこ: 3198 tweets, last: 病んでいたときの記憶を封印したので当時の細かい事実関係を正確に思い出せないから、雰囲気でいい加減なことを言っているアカウントですが、ただ、20代のときに年収が250万円を越えたことはない、これは記録に残っている紛れもない事実であります。 Fri Mar 23 13:28:53 +0000 2018
ツイート毎のリストが出来ました。
ただ、最後に投稿時間の情報がくっついているので、ツイート本文と分けておく必要があります。
# 2人のツイートを連結
df = pd.DataFrame({'uid': 'NekoAntarctica' , 'line' : NekoAntarctica_list})
df = df.append(pd.DataFrame({'uid': 'nardtree', 'line': nardtree_list}), ignore_index=True)
df.shape
(6387, 2)
分析用にいくつかの列を追加しておきます。
# コンテンツを本文と日付に分割
df['dt'] = [line[-31:] for line in df['line']]
df['tweet'] = [line[:len(line)-31] for line in df['line']]
# 分割前のコンテンツはもう要らないので除外
df = df.iloc[:, df.columns != 'line']
# ツイートの長さの列を追加
df['tw_len'] = [ len(tw) for tw in df['tweet'] ]
# リツイートかどうかの列を追加
df['RT'] = [s[0:2] == 'RT' for s in df['tweet']]
df.head()
uid | dt | tweet | tw_len | RT | |
---|---|---|---|---|---|
0 | NekoAntarctica | Fri Mar 23 13:28:53 +0000 2018 | 病んでいたときの記憶を封印したので当時の細かい事実関係を正確に思い出せないから、雰囲気でいい... | 118 | False |
1 | NekoAntarctica | Fri Mar 23 13:25:36 +0000 2018 | スクフェスする時間がない | 12 | False |
2 | NekoAntarctica | Fri Mar 23 13:21:31 +0000 2018 | ※当時は本当に人生修羅場過ぎて、記憶の詳細が曖昧なので、細かい事実関係が雑なのは許してほしい... | 103 | False |
3 | NekoAntarctica | Fri Mar 23 12:31:21 +0000 2018 | ふたりせぞん~ふたりせぞん~ | 14 | False |
4 | NekoAntarctica | Fri Mar 23 12:06:15 +0000 2018 | 南極や ああ南極や 難局や | 13 | False |
形態素解析前の処理はこんなところでしょうか。
後で再利用するため、ここまでの処理を関数にまとめておきます。
def tw_text_to_df(user_ids, textfiles):
df = pd.DataFrame(columns=['uid', 'line'])
for uid, text in zip(user_ids, textfiles):
twlist = tw_list_from_text(text)
df = df.append(pd.DataFrame({'uid': uid, 'line': twlist}), ignore_index=True)
df['dt'] = [line[-31:] for line in df['line']]
df['tweet'] = [line[:len(line)-31] for line in df['line']]
df = df.iloc[:, df.columns != 'line']
df['tw_len'] = [ len(tw) for tw in df['tweet'] ]
df['RT'] = [s[0:2] == 'RT' for s in df['tweet']]
return df
ツイートの形態素解析に入る前に、軽くデータの傾向を見ておきます。
# ツイートの長さの分布を確認
df.hist(column='tw_len')
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x1a11536710>]], dtype=object)
ツイート数の制限である140文字のところにピークがありますね。もう少し詳しく見てみます。
# ユーザー、RT別に確認
df.hist(column='tw_len', by=['uid', 'RT'], figsize=(12,10), sharex=True, sharey=True)
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x1a11ee0ba8>, <matplotlib.axes._subplots.AxesSubplot object at 0x1a11f86320>], [<matplotlib.axes._subplots.AxesSubplot object at 0x1a11f20240>, <matplotlib.axes._subplots.AxesSubplot object at 0x1a11fc7240>]], dtype=object)
南極にゃんこさんはツイート短め。
nardtreeさんはリツイート多めの傾向です。
ユーザー別のリツイート率も見てみましょう。
# リツイート率を確認するデータフレームを作成
df_pv = pd.DataFrame()
df_pv['tw_count'] = df.groupby('uid')['tweet'].count()
df_pv['RT_count'] = df.groupby('uid')['RT'].sum()
df_pv['RT_rate'] = df_pv['RT_count'] / df_pv['tw_count']
df_pv['RT_rate'].plot(kind='bar')
<matplotlib.axes._subplots.AxesSubplot at 0x1a120654a8>
nardtreeさんのリツイート率は4割を超えています。やるまでもないと思いますが、一応カイ二乗検定の結果をみてみます。
# 分割表の作成
df_pv['noRT_count'] = df_pv['tw_count'] - df_pv['RT_count']
df_cross = df_pv[['RT_count', 'noRT_count']]
df_cross
RT_count | noRT_count | |
---|---|---|
uid | ||
NekoAntarctica | 463.0 | 2735.0 |
nardtree | 1365.0 | 1824.0 |
from scipy import stats
chsq, p, dof, ef = stats.chi2_contingency(df_cross)
print("p-value: {:1.6f}".format(p))
p-value: 0.000000
二人が同一人物である可能性はかなり低そうです。
ここからは、全ツイートを形態素解析して辞書を準備し、最終的にツイート毎のベクトル特徴量を作っていきます。
# MeCabで1ツイートだけ解析してみる
tagger = MeCab.Tagger()
output = tagger.parse(df.loc[500, 'tweet'])
tagger_test_dt = pd.DataFrame( [line.replace('\t', ',').split(',') for line in output.split('\n')] )
tagger_test_dt
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 俺 | 名詞 | 代名詞 | 一般 | * | * | * | 俺 | オレ | オレ |
1 | は | 助詞 | 係助詞 | * | * | * | * | は | ハ | ワ |
2 | 空中 | 名詞 | 一般 | * | * | * | * | 空中 | クウチュウ | クーチュー |
3 | 浮遊 | 名詞 | サ変接続 | * | * | * | * | 浮遊 | フユウ | フユー |
4 | し | 動詞 | 自立 | * | * | サ変・スル | 連用形 | する | シ | シ |
5 | てる | 動詞 | 非自立 | * | * | 一段 | 基本形 | てる | テル | テル |
6 | から | 助詞 | 接続助詞 | * | * | * | * | から | カラ | カラ |
7 | 揺れ | 動詞 | 自立 | * | * | 一段 | 未然形 | 揺れる | ユレ | ユレ |
8 | なかっ | 助動詞 | * | * | * | 特殊・ナイ | 連用タ接続 | ない | ナカッ | ナカッ |
9 | た | 助動詞 | * | * | * | 特殊・タ | 基本形 | た | タ | タ |
10 | EOS | None | None | None | None | None | None | None | None | None |
11 | None | None | None | None | None | None | None | None | None |
うまく分割できているようです。
今回は品詞などの情報は使わず、シンプルに単語のみ使うことにします。
# MeCabで分割して単語のリストを取得する関数
def split_by_mecab(content, tagger):
parsed = tagger.parse(content)
word_list = [ line.split('\t')[0] for line in parsed.split('\n') ]
return word_list[:-2]
# 関数のテスト
split_by_mecab(df.loc[500,'tweet'], tagger)
['俺', 'は', '空中', '浮遊', 'し', 'てる', 'から', '揺れ', 'なかっ', 'た']
次に全ツイートを形態素解析して辞書を作成していきます。
# 全ツイートをMeCabで分割
whole_tweets = [split_by_mecab(tweet, tagger) for tweet in df['tweet'].tolist() ]
len(whole_tweets)
6387
# gensimで辞書を作成
dictionary = corpora.Dictionary(whole_tweets)
len(dictionary)
21757
辞書のサイズが大きいと後の学習で辛いので、程々に減らしておきます。
# 極端に頻度が少ない単語と多い単語を除外
dictionary.filter_extremes(no_below=2, no_above=0.8)
len(dictionary)
9108
辞書が準備できたので、ツイート毎のベクトル特徴量を作ります。
# 特徴量を作成
bows = [dictionary.doc2bow(tweet) for tweet in whole_tweets]
x = [list(matutils.corpus2dense([bow], num_terms=len(dictionary)).T[0]) for bow in bows]
x = np.array(x, dtype=np.float32)
x.shape
(6387, 9108)
教師データを用意します。
y = df['uid'].as_matrix()
y.shape
(6387,)
# 訓練データとテストデータを作成
from sklearn.model_selection import train_test_split
X_train, X_test, Y_train, Y_test = train_test_split(x, y, random_state=0)
print(X_train.shape, X_test.shape)
(4790, 9108) (1597, 9108)
再利用のため、ここまでの作業を関数にしておきます。
# データフレームから学習用データセットを返す
def df_to_train_test_split(df):
whole_tweets = [split_by_mecab(tweet, tagger) for tweet in df['tweet'].tolist() ]
dictionary = corpora.Dictionary(whole_tweets)
dictionary.filter_extremes(no_below=2, no_above=0.8)
bows = [dictionary.doc2bow(tweet) for tweet in whole_tweets]
x = [list(matutils.corpus2dense([bow], num_terms=len(dictionary)).T[0]) for bow in bows]
x = np.array(x, dtype=np.float32)
y = df['uid'].as_matrix()
return train_test_split(x, y, random_state=0)
前処理が終わったので、ようやく学習の時間です。
モデル選択ですが、セオリーでは、単語ベクトルのような高次元のスパースなデータは線形モデルが良いとされています。
今回は Binary Classification なので、ロジスティック回帰モデルを選択する事にします。
# ロジスティック回帰モデルでグリッドサーチ
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import LogisticRegression
logistic_param_grid = { 'C' : [0.1, 1, 10], 'penalty' : ['l1', 'l2'] }
lgs = GridSearchCV( LogisticRegression(), logistic_param_grid, cv=5 )
lgs.fit(X_train, Y_train)
GridSearchCV(cv=5, error_score='raise', estimator=LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True, intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1, penalty='l2', random_state=None, solver='liblinear', tol=0.0001, verbose=0, warm_start=False), fit_params=None, iid=True, n_jobs=1, param_grid={'C': [0.1, 1, 10], 'penalty': ['l1', 'l2']}, pre_dispatch='2*n_jobs', refit=True, return_train_score='warn', scoring=None, verbose=0)
サクッと完了。再利用のため、ここまでの作業を関数にしておきます。
# グリッドサーチの結果を返す
def logistic_grid_search(X_train, Y_train):
param_grid = { 'C' : [0.1, 1, 10], 'penalty' : ['l1', 'l2'] }
grid_search = GridSearchCV( LogisticRegression(), param_grid, cv=5 )
grid_search.fit(X_train, Y_train)
return grid_search
グリッドサーチで最適だったパラメータを確認します。
lgs.best_params_
{'C': 1, 'penalty': 'l2'}
結局、デフォルトのパラメータが最適でした。
訓練データに対するスコアと、テストセットに対するスコアを確認します。
lgs.best_score_
0.83716075156576197
lgs.score(X_test, Y_test)
0.81903569192235437
テストセットで約0.820…ビミョーですね。
念のため混同行列を確認しておきます。
# ロジスティック回帰モデルのテストセットに対する混同行列
logreg_pred = lgs.predict(X_test)
from sklearn.metrics import confusion_matrix
confusion = confusion_matrix(Y_test, logreg_pred)
confusion_df = pd.DataFrame(confusion, columns= ['Negative', 'Positive'], index=['True', 'False'])
confusion_df
Negative | Positive | |
---|---|---|
True | 665 | 124 |
False | 165 | 643 |
分類に失敗したツイートの傾向を見てみます。
# 分類に失敗したツイートを確認する
df['pred'] = lgs.predict( x )
df['match'] = (df['uid'] == df['pred'])
df_fail = df[df['match'] == False]
df_fail.head()
uid | dt | tweet | tw_len | RT | pred | match | |
---|---|---|---|---|---|---|---|
15 | NekoAntarctica | Fri Mar 23 06:00:06 +0000 2018 | RT @tokyoxxxclub: シモキタを紹介する、画像をつくっよ☝️😉✨ https:... | 63 | True | nardtree | False |
39 | NekoAntarctica | Thu Mar 22 06:07:17 +0000 2018 | @Sunset_Yuhi 大丈夫かどうかは今後の当局の方針と本人の努力次第でしょう。 | 42 | False | nardtree | False |
42 | NekoAntarctica | Thu Mar 22 05:33:39 +0000 2018 | @Sunset_Yuhi 顧客情報の流出と20代でリスクを取ることに何の関係があるのでしょうか | 47 | False | nardtree | False |
44 | NekoAntarctica | Wed Mar 21 21:09:49 +0000 2018 | RT @tkf: 何がどうダメかを正確にディスりたいのでMATLABやりたいなって常々おもっている | 49 | True | nardtree | False |
65 | NekoAntarctica | Wed Mar 21 12:56:27 +0000 2018 | なるほどね https://t.co/Rrx7tXNwcx | 29 | False | nardtree | False |
df_fail.tail()
uid | dt | tweet | tw_len | RT | pred | match | |
---|---|---|---|---|---|---|---|
6332 | nardtree | Sat Feb 03 12:10:52 +0000 2018 | もうダメです… | 7 | False | NekoAntarctica | False |
6341 | nardtree | Sat Feb 03 11:53:30 +0000 2018 | FF外でもこれだけ言ってる刺さらなくていい人に刺さってめんどい | 31 | False | NekoAntarctica | False |
6366 | nardtree | Sat Feb 03 08:39:45 +0000 2018 | RT @A_Researcher: 文系レポートを書くときは次の点を守ってください。 1.自... | 140 | True | NekoAntarctica | False |
6370 | nardtree | Sat Feb 03 06:46:22 +0000 2018 | あほくさ | 4 | False | NekoAntarctica | False |
6381 | nardtree | Sat Feb 03 06:10:25 +0000 2018 | ひどいにほんごだ | 8 | False | NekoAntarctica | False |
傾向がありそうですね…ちょっと確認してみましょう。
# リツイートかどうか?別に失敗したツイート長の分布を確認
df_fail.hist(column='tw_len', by=['uid', 'RT'] ,figsize=(12,10), sharex=True, sharey=True)
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x1a17a07278>, <matplotlib.axes._subplots.AxesSubplot object at 0x1a17a305f8>], [<matplotlib.axes._subplots.AxesSubplot object at 0x1a17971a20>, <matplotlib.axes._subplots.AxesSubplot object at 0x1a17ad3d30>]], dtype=object)
# 分類に失敗したツイート中のリツイートの割合
df_fail_pv = pd.DataFrame()
df_fail_pv['tw_count'] = df_fail.groupby('uid')['tweet'].count()
df_fail_pv['RT_count'] = df_fail.groupby('uid')['RT'].sum()
df_fail_pv['RT_rate'] = df_fail_pv['RT_count'] / df_fail_pv['tw_count']
df_fail_pv['RT_rate'].plot(kind='bar')
<matplotlib.axes._subplots.AxesSubplot at 0x1a17adfe80>
リツイートと短いツイートは失敗しやすい傾向が有るようです。
これは考えてみると当たり前で、リツイートの中身は他人のツイートなので、ツイート主の特徴を(直接には)反映しません。
また、短いツイートは特徴の表現力に乏しいと考えると自然な結果です。
(例外として、ラジッとかめるっという一言だけで特徴を表現できる場合もありますが…)
試しに、10文字以下のツイートとRTを除いてリトライさせてみます。
# 10文字以下のツイートとRTを除く
df_sub = df[df['tw_len'] > 10]
df_sub = df_sub[df_sub['RT'] == False]
X_train, X_test, Y_train, Y_test = df_to_train_test_split(df_sub)
grid_search_sub = logistic_grid_search(X_train, Y_train)
print(grid_search_sub.best_score_)
print(grid_search_sub.score(X_test, Y_test))
0.878587196468 0.890255439924
テストセットでのスコアが約0.89まで上昇しました。
後はデータを増やす、形態素解析後の前処理を頑張るなどすればもう少し上げられそうです。
まったくキャラの異なるツイート主@TJO_datasciとの分類を試してみます。
tjo_list = tw_list_from_text('TJO.txt')
print(tjo_list[0])
@issei_sato 査読付き国際会議としては肥大化し過ぎてるんじゃないでしょうか(総量がジャーナルメインの分野と同等なら問題ないかもですが) Fri Mar 23 13:40:23 +0000 2018
# 南極にゃんこ - TJO ペアのデータフレームを作成
df_n_tjo = tw_text_to_df(['NekoAntarctica', 'TJO'], ['NekoAntarctica.txt', 'TJO.txt'])
# 10文字以下のツイートとRTを除外
df_n_tjo = df_n_tjo[df_n_tjo['tw_len'] > 10]
df_n_tjo = df_n_tjo[df_n_tjo['RT'] == False]
# ロジスティック回帰で学習
X_train, X_test, Y_train, Y_test = df_to_train_test_split(df_n_tjo)
grid_search_ntjo = logistic_grid_search(X_train, Y_train)
print(grid_search_ntjo.best_score_)
print(grid_search_ntjo.score(X_test, Y_test))
0.920045045045 0.926582278481
nardtree, 南極にゃんこ ペアと比較して、スコアが伸びました!キャラの違いが数字に現れたと言って良いのではないでしょうか?