落合式Twitterクソリプ対策の考察


###１．イントロダクション
昨年の夏頃から筑波大学 准教授の落合陽一さんに興味をもち、彼の著書や講演，Twitter(@ochyai)を見ています。
彼の行動やその交友の広がりをみていると、エンターテーメントで面白く感化されます。

そんな落合さんが，8月上旬からTwitterでの攻撃的かつ悪意のある反応（ここでの”クソリプ”の定義）に対して、それを流すような空気ではなく、しっかりと対応・反論し、インターネット世界のネガティブな空気をなくそうとされています。

私はネットは善意の世界と思って（願って）おり、プログラムに関してネットの各種の質問箱にわからないことを聞くと、ほんの数時間で回答があり、世界中に仲間がいる気分です。そして、私からのコントリビューションが、このQiitaになります。

前置きはさておき、そういったリプ対応がどれほど効果があるのか、根っからの実験屋の私には高い関心があります。関心は学習の燃料となり、今回の考察につながりました。

では、Twitterのツイート数のトレンドから何がわかるのか、解析し考察してみます。

**注意： この考察は個人の見解であり，不特定多数の同意ではありません**

###2.　Twitter取得プログラム

TwitterのAPIを使ってツイートを取得します。
取得方法について、[こちらの方のページ](http://www.analyze-world.com/entry/2017/12/08/072942)を参考にしました。ありがとうございます。

Twitterのツイートの取得のためには、Twitterのアカウント登録（Consumer Key、Consumer Secret、Access Token、Access Token Secret）が必要です。取得方法は以下を参考にしました。
[Twitter API Key を取得する方法](http://phiary.me/twitter-api-key-get-how-to/)

一度に取得できるツイートは１５０００件程度であり、そのあとは１５分ほど接続を待つ必要があります。
Twitterのデータ取得のプログラムは、以下のページを参考にさせていただきました。
[データで見るM1グランプリ2017　〜データ取得編〜](http://www.analyze-world.com/entry/2017/12/08/072844)

ここでは、ツイート内容を検索して、その条件に合致するものを取得します。
今回は落合さん自身のツイートも取得し、落合さんのツイートに対するリプライを見る必要があります。そのプログラムとして以下を追加しています。

```python:TwitterGetterByUser.py
class TweetsGetterByUser(TweetsGetter):
    '''
    ユーザーを指定してツイートを取得
    '''

    def __init__(self, screen_name):
        super(TweetsGetterByUser, self).__init__()
        self.screen_name = screen_name

    def specifyUrlAndParams(self):
        '''
        呼出し先 URL、パラメータを返す
        '''
        url = 'https://api.twitter.com/1.1/statuses/user_timeline.json'
        params = {'screen_name': self.screen_name, 'count': 200}
        return url, params

    def pickupTweet(self, res_text):
        '''
        res_text からツイートを取り出し、配列にセットして返却
        '''
        results = []
        for tweet in res_text:
            results.append(tweet)

        return results

    def getLimitContext(self, res_text):
        '''
        回数制限の情報を取得 （起動時）
        '''
        remaining = res_text['resources']['statuses']['/statuses/user_timeline']['remaining']
        reset = res_text['resources']['statuses']['/statuses/user_timeline']['reset']

        return int(remaining), int(reset)
```
そして、取得したツイートデータから、５分間でのツイート数を積算カウント（Index)し、それを時系列のトレンドグラフを作成します。

```python:TwitterGraph.py
# 各シリーズをデータフレーム化
m1_df = pd.concat([created_at, text], axis=1)

# カラム名
m1_df.columns = ['created_at', 'text']

# csvファイルとして保存。　ツイート内容も含む。
m1_df.to_csv('ochiai180819.csv', sep='\t', encoding='utf-16')

# 各シリーズをデータフレーム化
m2_df = pd.concat([created_at],axis=1)

# カラム名
m2_df.columns=['created_at']

# csvファイルとして保存。ツイート時の時刻のみを保存。
m2_df.to_csv('ochiai1808191.csv', sep = ',',encoding='utf-8')

# CSV ファイル (employee.csv) として読み込む
df2 = pd.read_csv('ochiai1808191.csv', sep=',', parse_dates=['created_at'], engine='python')
df3 = df2.set_index('created_at').resample('5T').count() #5Tが５分毎のパラメータ。
df3.columns = ['Index']

# csv ファイルとして出力。別のプログラムで使用するために別保存。
df3.to_csv('ochiai1808192.csv', sep =',', encoding='utf-8')

# matplotlibを使ってグラフ化する
df3.plot()
# df3.plot(logy=True)　# Y軸を対数表示するときは有効にする
plt.title("Twitter Trend")
plt.xlabel("Date")
plt.ylabel("The number of twitter")
pylab.savefig('ochiai180819.png') # 保存するグラフのファイル名
```
これで、ツイート内容や発信時刻や、その時間トレンドへのデータの変換、グラフ化もできます。
ここで、プログラム全体を示します。

環境
Python 2.7
ライブラリ；
requests_oauthlib,jsondatetime, time, sys, abc,  pandas, matplotlib.pyplot, pylab, dateutil.parser

注意事項
・時刻は世界標準時間（UST：Universal Standard Time)　日本時刻への変換は、この時刻に＋９時間。
・下記のプログラムは、”キーワードでの検索”を実行する。対象のアカウント（今回は"ochyai"）のときは、’ユーザを指定して取得’を有効にする。（キーワードで検索は、#で無効にする）

```python:TwitterSearchGraph.py
# coding:utf-8

from requests_oauthlib import OAuth1Session
import json
import datetime, time, sys
from abc import ABCMeta, abstractmethod
import pandas as pd
from pandas import Series, DataFrame
import matplotlib.pyplot as plt
import pylab
from dateutil.parser import parse

CK = '********************'
CS = '**********************************'
AT = '**********************************'
AS = '**********************************'


class TweetsGetter(object):
    __metaclass__ = ABCMeta

    def __init__(self):
        self.session = OAuth1Session(CK, CS, AT, AS)

    @abstractmethod
    def specifyUrlAndParams(self, keyword):
        '''
        呼出し先 URL、パラメータを返す
        '''

    @abstractmethod
    def pickupTweet(self, res_text, includeRetweet):
        '''
        res_text からツイートを取り出し、配列にセットして返却
        '''

    @abstractmethod
    def getLimitContext(self, res_text):
        '''
        回数制限の情報を取得 （起動時）
        '''

    def collect(self, total=-1, onlyText=False, includeRetweet=False):
        '''
        ツイート取得を開始する
        '''

        # ----------------
        # 回数制限を確認
        # ----------------
        self.checkLimit()

        # ----------------
        # URL、パラメータ
        # ----------------
        url, params = self.specifyUrlAndParams()
        params['include_rts'] = str(includeRetweet).lower()
        # include_rts は statuses/user_timeline のパラメータ。search/tweets には無効

        # ----------------
        # ツイート取得
        # ----------------
        cnt = 0
        unavailableCnt = 0
        while True:
            res = self.session.get(url, params=params)
            if res.status_code == 503:
                # 503 : Service Unavailable
                if unavailableCnt > 10:
                    raise Exception('Twitter API error %d' % res.status_code)

                unavailableCnt += 1
                print ('Service Unavailable 503')
                self.waitUntilReset(time.mktime(datetime.datetime.now().timetuple()) + 30)
                continue

            unavailableCnt = 0

            if res.status_code != 200:
                raise Exception('Twitter API error %d' % res.status_code)

            tweets = self.pickupTweet(json.loads(res.text))
            if len(tweets) == 0:
                # len(tweets) != params['count'] としたいが
                # count は最大値らしいので判定に使えない。
                # ⇒  "== 0" にする
                # https://dev.twitter.com/discussions/7513
                break

            for tweet in tweets:
                if (('retweeted_status' in tweet) and (includeRetweet is False)):
                    pass
                else:
                    if onlyText is True:
                        yield tweet['text']
                    else:
                        yield tweet

                    cnt += 1
                    if cnt % 100 == 0:
                        print ('%d件 ' % cnt)

                    if total > 0 and cnt >= total:
                        return

            params['max_id'] = tweet['id'] - 1

            # ヘッダ確認 （回数制限）
            # X-Rate-Limit-Remaining が入ってないことが稀にあるのでチェック
            if ('X-Rate-Limit-Remaining' in res.headers and 'X-Rate-Limit-Reset' in res.headers):
                if (int(res.headers['X-Rate-Limit-Remaining']) == 0):
                    self.waitUntilReset(int(res.headers['X-Rate-Limit-Reset']))
                    self.checkLimit()
            else:
                print ('not found  -  X-Rate-Limit-Remaining or X-Rate-Limit-Reset')
                self.checkLimit()

    def checkLimit(self):
        '''
        回数制限を問合せ、アクセス可能になるまで wait する
        '''
        unavailableCnt = 0
        while True:
            url = "https://api.twitter.com/1.1/application/rate_limit_status.json"
            res = self.session.get(url)

            if res.status_code == 503:
                # 503 : Service Unavailable
                if unavailableCnt > 10:
                    raise Exception('Twitter API error %d' % res.status_code)

                unavailableCnt += 1
                print ('Service Unavailable 503')
                self.waitUntilReset(time.mktime(datetime.datetime.now().timetuple()) + 30)
                continue

            unavailableCnt = 0

            if res.status_code != 200:
                raise Exception('Twitter API error %d' % res.status_code)

            remaining, reset = self.getLimitContext(json.loads(res.text))
            if (remaining == 0):
                self.waitUntilReset(reset)
            else:
                break

    def waitUntilReset(self, reset):
        '''
        reset 時刻まで sleep
        '''
        seconds = reset - time.mktime(datetime.datetime.now().timetuple())
        seconds = max(seconds, 0)
        print ('\n     =====================')
        print ('     == waiting %d sec ==' % seconds)
        print ('     =====================')
        sys.stdout.flush()
        time.sleep(seconds + 10)  # 念のため + 10 秒

    @staticmethod
    def bySearch(keyword):
        return TweetsGetterBySearch(keyword)

    @staticmethod
    def byUser(screen_name):
        return TweetsGetterByUser(screen_name)


# キーワード検索でツイートを取得します。以下の通り、キーワード検索によるツイート取得関数を定義します。

class TweetsGetterBySearch(TweetsGetter):
    '''
    キーワードでツイートを検索
    '''

    def __init__(self, keyword):
        super(TweetsGetterBySearch, self).__init__()
        self.keyword = keyword

    def specifyUrlAndParams(self):
        '''
        呼出し先 URL、パラメータを返す
        '''
        url = 'https://api.twitter.com/1.1/search/tweets.json?'
        params = {'q': self.keyword, 'count': 100}
        return url, params

    def pickupTweet(self, res_text):
        '''
        res_text からツイートを取り出し、配列にセットして返却
        '''
        results = []
        for tweet in res_text['statuses']:
            results.append(tweet)

        return results

    def getLimitContext(self, res_text):
        '''
        回数制限の情報を取得 （起動時）
        '''
        remaining = res_text['resources']['search']['/search/tweets']['remaining']
        reset = res_text['resources']['search']['/search/tweets']['reset']

        return int(remaining), int(reset)


created_at = []
text = []

# ユーザを指定して発信したツイートを取得します。

class TweetsGetterByUser(TweetsGetter):
    '''
    ユーザーを指定してツイートを取得
    '''

    def __init__(self, screen_name):
        super(TweetsGetterByUser, self).__init__()
        self.screen_name = screen_name

    def specifyUrlAndParams(self):
        '''
        呼出し先 URL、パラメータを返す
        '''
        url = 'https://api.twitter.com/1.1/statuses/user_timeline.json'
        params = {'screen_name': self.screen_name, 'count': 200}
        return url, params

    def pickupTweet(self, res_text):
        '''
        res_text からツイートを取り出し、配列にセットして返却
        '''
        results = []
        for tweet in res_text:
            results.append(tweet)

        return results

    def getLimitContext(self, res_text):
        '''
        回数制限の情報を取得 （起動時）
        '''
        remaining = res_text['resources']['statuses']['/statuses/user_timeline']['remaining']
        reset = res_text['resources']['statuses']['/statuses/user_timeline']['reset']

        return int(remaining), int(reset)

# キーワードで取得
getter = TweetsGetter.bySearch(u'@ochyai AND -filter:retweets AND until:2018-8-19_00:00:00_UST')

# ユーザーを指定して取得 （screen_name）するときは、ここを有効にします。ユーザ名でデータ取得は、取得日から前1ヶ月間のツイートが取得できます。
# getter = TweetsGetter.byUser('ochyai')

cnt = 0
for tweet in getter.collect(total=1000000):
    # cnt += 1
    # print ('------ %d' % cnt)
    # print ('{} {} {}'.format(tweet['id'], tweet['created_at'], '@'+tweet['user']['screen_name']))
    # print (tweet['text'])
    created_at.append(tweet['created_at'])
    text.append(tweet['text'])

created_at = Series(created_at)
text = Series(text)

# 各シリーズをデータフレーム化
m1_df = pd.concat([created_at, text], axis=1)

# カラム名
m1_df.columns = ['created_at', 'text']

# csvファイルとして保存
m1_df.to_csv('ochiai180818E.csv', sep='\t', encoding='utf-16')

# 各シリーズをデータフレーム化
m2_df = pd.concat([created_at],axis=1)

# カラム名
m2_df.columns=['created_at']

# csvファイルとして保存
m2_df.to_csv('ochiai180818E1.csv', sep = ',',encoding='utf-8')

# CSV ファイル (employee.csv) として読み込む
df2 = pd.read_csv('ochiai180818E1.csv', sep=',', parse_dates=['created_at'], engine='python')
df3 = df2.set_index('created_at').resample('5T').count()
df3.columns = ['Index']

# csv ファイルとして出力
df3.to_csv('ochiai180818E2.csv', sep =',', encoding='utf-8')

# matplotlibを使ってグラフ化する
df3.plot()
# df3.plot(logy=True)　# Y軸を対数表示するときは有効にする
plt.title("Twitter Trend")
plt.xlabel("Date")
plt.ylabel("The number of twitter")
pylab.savefig('ochiai180818E.png') # 保存するグラフのファイル名

```
###3. データの取得結果と考察
では，前項のプログラムを実行結果していきます。
これは落合さんへのリプライのトレンド（縦軸は５分間のリプライ数）になります。

![ochiai180818E.png](https://qiita-image-store.s3.amazonaws.com/0/187602/35be152d-c5f3-5e24-34de-fded82b959d4.png)

ここから、８月９日の午後、および８月１１日の午前に落合さんへのリプライが増えているのがわかります。

本来は、リプライの中でも”クソリプ”とそうでないものを区別し、”クソリプ”のみの反応数をみて、落合さんからのアクションに対してどのように変化するのか（増えるのか、減るのか）注目したかったのですが、”クソリプ”の文章（キーワード）が共通していないため、よいキーワードが選べず抽出することができませんでした。
そこで、よい反応も含まれますが、リプライ数のみで評価し、その結果を考察します。

まず、８月９日のリプライ数の変化を細かく見ていきます。

![Ochiai180819E093.png](https://qiita-image-store.s3.amazonaws.com/0/187602/d93edc3c-54d8-c0c5-0b17-fb47327a1511.png)

これをみると、８月９日の１６時（UT)、日本時間だと９時間プラスして８月１０日の午前１時ごろにツイートがきっかけと思われます。
このきっかけとなったであろう落合さんのツイートは以下になります。

![Ochiai1808091600.png](https://qiita-image-store.s3.amazonaws.com/0/187602/16302d17-1492-e3a7-ab28-837ce26b9f54.png)

深夜でしたが、落合さんへのリプライに対して，落合さんが極端なまでに反応されているのがわかります。
落合さんのツイートおよび落合さんへのリプライの関係（相関）を確かめるため、同じグラフで並べてみます。

普段は、エクセルやカレイダグラフを使ってグラフ化しているのですが、ここでははじめて[SuperMjograph](http://www.mjograph.net/indexj.html)を使ってみました。個人使用である限り無料で使うことができ、直感的にグラフ化できるのでおすすめです。

![Trend tweet or reply1.png](https://qiita-image-store.s3.amazonaws.com/0/187602/f6c0ffcb-9fdf-8ab0-8628-b2ab0188e530.png)

緑が落合さんへのリプライ数、紫が落合さんのツイート数になります。

まず、８月９日を注目してみます。

![Trend180809.png](https://qiita-image-store.s3.amazonaws.com/0/187602/67e244a0-b380-5ed6-ee30-b3fdc9b3be6a.png)

ここで面白いのは、落合さんへのリプライが増えると、それに対して落合さんが反応のツイートを行い、そのイタチごっこがつづいています。
１時間ほどこの状態が続き、リプライ数とツイート数がほぼ同数になった後（ここは”重要”）、両者とも同じタイミングでリプライ数およびツイート数が大きく減少しています。

この間のリプライおよびツイート内容をみてみると、当初はリプライは”クソリプ”に分類されるものが多かったのですが、後半になるとその分類のものは大きく減少しています。これは、落合さんによる反応速度がクソリプ反応に勝った結果、見事に撃退できていると推測します。（クソリプの持続時間はせいぜい１時間程度？）


次にリプライ数が増えているのが、８月１１日の午前５時４０分、日本時間だと９時間プラスして、同じ８月１１日の午後２時４０分ごろになります。


![Reply1808110540.png](https://qiita-image-store.s3.amazonaws.com/0/187602/b7c2b861-023d-7574-1709-dd5dc49b2268.png)

きっかけになったのと思われるのが、落合さんのこのツイートです。

![OchiaiTwitter.png](https://qiita-image-store.s3.amazonaws.com/0/187602/0570bae1-720e-ea16-6f9f-724ca0a01238.png)


> 母が結婚の顔合わせのときに「この子の最終到達点は自分の能力低下を許せなくて自殺することですけど結婚していいんですか？」って言ってて流石だと思った． Tweet @ochyai

そして、それに対するリプライの一部がこちらです。

![Reply1808110540re.png](https://qiita-image-store.s3.amazonaws.com/0/187602/e07ef9bd-3e80-28a5-954b-1195f93f8042.png)

このときのリプライの内容をみると、”クソリプ”に分類する内容よりも、多くは”驚き”のリアクションになっています。
そのため、落合さんはそれらに対してい大きく反応することがなかったためか、リプライ数のトレンドは８月９日のものと大きく異なっています。

![Trend0811.png](https://qiita-image-store.s3.amazonaws.com/0/187602/3847c943-71c7-7a96-0e45-7c89de090781.png)


８月９日と比べて、リプライ数に対して落合さんのツイート数がそれほど大きくなっていません。またリプライ数も徐々に低下しています。
話題となって、その熱量が自然と下がっていった、と見られます。

こういったトレンドをみると、クソリプを撃退しているとき、ツイートが話題となっているときが、トレンドから推測できるのではないかと思いました。


ここで、そもそも世間一般に言われる”クソリプ”ってどれだけツイートされているのか、その傾向から考察できないかと思いました。
ただ、どういった言葉が”クソリプ”と分類されるツイートかはケースバイケースであるため、ここは”クソリプ”というキーワードでそのトレンドを調べてみました。

![kuso180819.png](https://qiita-image-store.s3.amazonaws.com/0/187602/e73a9013-fef0-379e-6920-e431c39de4a0.png)

びっくりな結果です。”クソリプ”は毎日規則正しく行われています。営みです。
この傾向は人間の活動量がそのまま表されているのだと思います。
”クソリプ”することが目的であって、自分の主義・主張・反論・意見を述べる”手段”ではないのでしょう。


###4. まとめ
長くなりましたが、以下に今回の検証結果をまとめます。

**クソリプに意思はなく、その行為そのものが目的であるため、対象に意思を持って圧倒されれば消えていく。**

今後は別のケースや対象でもツイートのトレンドから考察していきたいと思います。


###注意
　プログラムにより取得したデータそのものは客観的な結果ですが、そこからの推測は個人の考察です。