流行語大賞を自動化してみたかった

2021/12/07に公開

はじめに

こんにちは。MIS.Wという団体に所属しております、まぐと申します。
先日、サークル内向けに年間流行語大賞をやろうと思いつき、実際やってみたところ色々難しいポイントがあったので、ここに得られた知見をまとめておきたいと思います。
(とはいえ、結局まだ上手く行っていないのですが……)

この企画のために書いたコードは以下のリポジトリに置かれています。詳しく見たい方はこちらをどうぞ。
https://github.com/3kanAlpha/misw-wordcloud

きっかけ

一般的には流行語大賞というと、大勢の人にアンケートをとってみて、集計して……みたいなイメージがあるのですが、うちのサークル員はTwitterユーザーが多いので、「これを集計すればそれっぽいことができるんじゃないか?」と思いました。
加えて、少し前にPythonにワードクラウドを作ることができるライブラリ[1]があると聞いていたので、これで簡単にビジュアライズもできて面白そうだと思ったわけです。

やることの手順

だいたいこんな感じになるはずです。

  1. なんらかの方法でTwitterからツイートを収集してくる。
  2. 集まったツイートの文字列データを形態素解析なりでsplitする。
  3. splitした文字列データをword_cloudライブラリに投げる。

それぞれの手順について詳しく見ていきます。

ツイートの収集をする

ツイートの収集というと、公式に提供されているTwitter APIを利用するのが最も簡単です。
https://developer.twitter.com/en/docs/twitter-api
殆どの場合この手法で十分事足りるかと思うのですが、今回のようにツイート取得対象の期間が長くツイートの総数が多くなることが想定される場合には、困った問題があります。
以下、Timelinesのドキュメントからの抜粋です。

The user Tweet timeline endpoint is a REST endpoint that receives a single path parameter to indicate the desired user (by user ID). The endpoint can return the 3,200 most recent Tweets, Retweets, replies and Quote Tweets posted by the user.

意訳:この機能では特定ユーザーの直近3200ツイートが取得できるよ[2]

「足りね~~」となるわけです。一般的なTwitterを利用しているオタクだと、少なく見積もっても500ツイート/月以上はしていると思うので、半年分くらいしか取れない。
あとこれは個人的な理由になるのですが、Twiiter API v2の設計がドキュメントを読んでもいまいち理解できなかったりもしたので、別の手段に頼ることにしました。

この後に頼ったライブラリが、twintです。
https://github.com/twintproject/twint
現在公開されているコードもこれを利用して書かれています。

詳細な使い方の説明はここでは省きますが、こんな感じで書くとツイートの検索ができます。

tweet_collector.py
# sinceからuntilまでのツイートを取得してJSON Lines形式で保存
def get_tweets(user_name, since, until):
    c = twint.Config()

    c.Username = user_name
    c.Store_object = True
    c.Hide_output = True
    c.Store_json = True
    c.Output = TWEETS_DIR + user_name + "/" + since + "_to_" + until + ".jsonl"
    c.Since = since
    c.Until = until
    c.Limit = 1000

    twint.run.Search(c)

まあ、正確には「ツイートの検索ができるはずでした」なのですが……。というのも、これで実際に検索をかけてみると、取得できないツイートが大量に発生します。おそらくtwintの不具合だと思われます。
代替案としてstweetというライブラリもあるそうですが、ドキュメントが少なすぎて使うのを諦めました。

そんなわけで一つ目の課題として、「1年分のツイートをどうやって取得すればいいか?」というのが発生しました。解決策募集中です。

ツイートのデータを整形する

前節でツイートが集められたのはいいのですが、そのままの状態だとJSON Lines形式[3]で取得したツイートのデータが保持されています。

個人的には、JSON形式でかつ全てのツイートが1ファイルに纏まった状態であって欲しかったので、データを整形してやることにしました。
こうして整形することのメリットなのですが、前節のプログラムでは3日分くらいずつ分割して検索をかける処理を行っています。

tweet_collector.py
# 今年のツイートを取得(できるはずだった)
def get_tweets_this_year(user_name, delta=1):
    make_user_dir(user_name)
    start_date = DATE_SINCE
    now = datetime.now()

    day_delta = timedelta(days=delta)

    while start_date < now:
        get_tweets(user_name, start_date.strftime("%Y-%m-%d"), (start_date + day_delta).strftime("%Y-%m-%d"))
        start_date += day_delta
        time.sleep(1)

こういう検索の仕方をすると、互いに連続する期間の検索データ中において重複して存在するレコードが発生することがあります。
2021/01/01から2021/01/04の検索データと、2021/01/04から2021/01/07の検索データのどちらにも2021/01/04のツイートが含まれてしまう、ということです。

これをそのままワードクラウド用の文字列データとして使うと、この重複している分の単語を二重にカウントしてしまうことになるので、正しい結果が得られません。

そういうわけで、整形ついでにこういう処理をまとめてしてあげると、このあとの処理のときに考えることを減らすことが出来ます。

実際には、以下のような処理にしています。

database_builder.py
def find_tweet(tweets, screen_name, tweet_id):
    fl = False

    for tweet in tweets:
        if tweet["id"] == tweet_id:
            fl = True
            break

    return fl

def combine_user_tweets(old_tweets, screen_name):
    dir_path = TWEETS_DIR + screen_name + "\\"
    files = glob.glob(dir_path + "*.jsonl")
    
    tweets = []

    for file in files:
        with jsonlines.open(file) as reader:
            for tweet in reader:
                if not find_tweet(tweets, screen_name, tweet["id"]):
                    tweets.append(tweet)

    return tweets

ツイートの重複をJSON Linesのレコード同士の比較ではなく、わざわざツイートのID比較による判定にしているのは、取得したタイミングの異なる検索データをまとめる処理をした場合、いいね数の違いなどによって同じツイートなのに同じレコードだと判定されないケースが発生しうるからです。

以上で、取得したデータをマージすることができました。

集めたツイートを分解する

欠けてるツイートがあるとはいえ、取れた分をとりあえず処理してみます。
処理ってなんやねんという話ですが、文字列を単語レベルに分解する処理のことです。

英語の文は元から空白区切りで単語ごとに分解されていますが、日本語の文となるとそうはいきません。ワードクラウドを作るために知りたいのは各単語の出現頻度なので、この前処理をする必要があります。

こういった形態素解析の処理には、今回MeCabを利用しました。
https://taku910.github.io/mecab/

また、今回のプログラム中では標準の辞書ではなくmecab-ipadic-NEologdを利用しています。

実際のコードは、以下のようになっています。

buzzword-2021.py
def get_words_from_text(text):
    mecab = MeCab.Tagger("-d D:\Documents\MeCabDic")
    text_dst = mecab.parse(text)

    lines = text_dst.split('\n')
    lines = lines[0:-2]

    words = []

    for line in lines:
        # tabかカンマでsplitする
        col = re.split('\t|,', line)
        
        if col[1] in ["形容詞", "動詞","名詞", "副詞"]:
            words.append(col[0])
    
    return words

MeCabに形態素解析自体の処理は全て任せているので、コード長としては大して長くありません。ありがたいことです。

形態素解析をした結果、形容詞・動詞・名詞・副詞のいずれかに該当すると判定された単語のみをワードクラウド用に出力する処理になっています。

ここに取得してきたツイートを全部投げてしまえば、形態素解析の段階は終わりというわけです。

ここで発生した課題については、後ほど。

ワードクラウドを生成する

さっき形態素解析して分解した単語データを全部連結して、word_cloudライブラリに投げるだけです。

buzzword-2021.py
def generate_wordcloud(text):
    wc = WordCloud(background_color="white", font_path=FONT_PATH, max_words=1000, max_font_size=300, width=800, height=600)
    wc.generate(text)

    wc.to_file("{}.png".format(WORDCLOUD_TITLE))

空白区切りのテキストを投げると、そこからワードクラウドを作ってくれます。マスク機能とかを使うとイラストの型に収まった形のワードクラウドとかも作れて、面白いです。

別にここの手順で話すことはあんまり無いですね。

結果と課題

とりあえず(おおよそ)上の手順に沿ってMIS.Wのサークル員のツイートを集計してみた結果がこんな感じ。

みす流行語大賞2021
謎の英文字列はほとんどTwitterのスクリーンネームです

うーん、一般的な流行語のイメージとはかけ離れていそうです。なんか「ん」とかあるし……。

何がだめだったのか?

簡単に言うと、文の分解の仕方がいまいちということになりそうです。

どんな単語が多いのか見るために、Counterを使って出現回数上位20個を見てみます。

buzzword-2021.py (modified)
words_to_wc = []

for user in user_ids["ids"]:
    if user["name"] in all_tweets:
        print("Loading {}'s tweets ... ({})".format(user["name"], len(all_tweets[user["name"]])))
        for tweet in all_tweets[user["name"]]:
            words_to_wc.extend(get_words_from_text(tweet["tweet"]))

counter = collections.Counter(words_to_wc)
counter_most = counter.most_common(20)

print(counter_most)

[('t', 3347), ('co', 3292), ('https', 3290), ('し', 3274), ('てる', 1758), ('の', 1687), ('ん', 1597), ('する', 1173), ('て', 1126), ('いい', 1026), ('こと', 842), ('なっ', 801), ('そう', 755), ('れ', 754), ('数', 700), ('さ', 654), ('これ', 646), ('ある', 641), ('1', 625), ('いる', 617)]

今回用いたデータでは、以上のような出力になりました。
なんとも、非本質的な単語が多いですね。いわゆる自然言語処理におけるStop wordsというやつも散見されます。
これらを省くには形態素解析して単語に分解するだけでは不十分で、その後にさらにフィルタをする必要がありそうです。

シンプルな対処として考えられるのは、総単語数Nと係数f \in (0, 1)によって閾値t := fNを定義して、出現回数がこれ以上であるような単語を除いてしまう処理です。(総単語数ではなくて総ツイート数でもいいかもしれません)
これを試してみましょう。

今回用いたデータはN=179985で、閾値は500くらいになってくれると良さそうな感じがするので、f=0.0028としてみます。

以下のような関数を定義しました。
(計算量O(N^2)になるの嫌なんですが、簡単のために妥協)

def remove_words_by_thresh(list, counter, factor=0.0028):
    N = len(list)
    THRESH = N * factor

    for s in list:
        if counter[s] > THRESH:
            list.remove(s)

これを以下のように呼び出してから、もう一度Counterに投げてみます。

words_to_wc = []

for user in user_ids["ids"]:
    if user["name"] in all_tweets:
        print("Loading {}'s tweets ... ({})".format(user["name"], len(all_tweets[user["name"]])))
        for tweet in all_tweets[user["name"]]:
            words_to_wc.extend(get_words_from_text(tweet["tweet"]))

print("Total words: {}".format(len(words_to_wc)))

remove_words_by_thresh(words_to_wc, collections.Counter(words_to_wc))

counter = collections.Counter(words_to_wc)
counter_most = counter.most_common(20)

print(counter_most)

[('t', 2943), ('てる', 506), ('課題', 479), ('8', 446), ('の', 436), ('3', 432), ('7', 430), ('見', 422), ('やっ', 416), ('なる', 406), ('6', 404), ('ん', 403), ('思っ', 397), ('4', 396), ('今日', 391), ('寝', 389), ('すぎ',
385), ('き', 372), ('れ', 368), ('でき', 364)]

「いや、先頭のtなんやねん」
というか、なんかうまく削除できていない……?

Pythonの気持ちはよく分かりませんが、とりあえず以下のように関数を修正すると動く。なんで?
そもそもこれ要素削除するときにイテレータどうなってんだ

def remove_words_by_thresh(list, counter, factor=0.0028):
    N = len(list)
    THRESH = N * factor

    for s in list:
        if counter[s] > THRESH:
            for i in range(counter[s]):
                list.remove(s)

修正した結果。

[('課題', 479), ('8', 446), ('3', 432), ('7', 430), ('見', 422), ('やっ', 416), ('なる', 406), ('6', 404), ('思っ', 397), ('4', 396), ('今日', 391), ('寝', 389), ('すぎ', 385), ('き', 372), ('でき', 364), ('9', 352), ('さん', 347), ('俺', 344), ('草', 338), ('気', 326)]

まあ、動いてるしいいか。

これ以上閾値を小さくすると必要な単語まで刈られてしまいそうなので、閾値による処理はこれが限界な気がします。

あとはそもそも単語レベルまで分解してしまうのは良くないという説があります。定型文とか、そういった複数要素で成り立つ流行語を補足できなくなるからです。
これは、形態素解析の処理を触らなくてはいけなくて、かなり難しそうです。

もっと他の手法を考えてみる

tf-idfというアルゴリズムがあるらしい。
これで評価してあげると、うまく一般性の高い単語を除くことができそうですが、今度は処理したデータをどうワードクラウドにしてあげればいいのか……?

疲れたので今回はこんなところで終わりにしましょう。

脚注
  1. https://github.com/amueller/word_cloud ↩︎

  2. 学術的な目的によるAPI利用の場合は、全ツイートを対象として検索などが可能なようです。 ↩︎

  3. https://jsonlines.org/ ↩︎

Discussion