Qiitaでバズりそうな記事タイトルの推定モデルを構築してみた

Pythonは触ってきたけど機械学習の実装経験が乏しい
→特に自然言語処理を使って何かを作ってみたい
・・・といった気持ちで、バズり推定モデルを構築してみました。

# 概要

* 記事タイトルのデータを収集  
* 記事タイトルのデータセットを1ファイルに結合
* スパム検知でまずは練習
* Qiita記事のデータセットに置き換え

# 実装

## 記事タイトルのデータを収集

### トレンドに載った記事

トレンドの記事を紹介しているTwitterアカウント（[Qiita 人気の投稿](https://twitter.com/qiitapoi)）から、Twitter APIを用いて取得します。
3229件のデータが集まり、ツイート内のURLと絵文字を除去してからjsonファイルにダンプしていきます。

```python
def retrieveTweets(screenName, count):
    global totalIdx
    timeLine = t.statuses.user_timeline(screen_name=screenName, count=count)
    maxId = 0
    for tweetsIdx, tweet in enumerate(timeLine):
        maxId = tweet["id"]
        addArticleTitles(tweet)
        totalIdx += 1
    print("Starting additional retrieving...")
    retrieveContinuedTweets(screenName, count, maxId)

def retrieveContinuedTweets(screenName, count, maxId):
    global totalIdx, isFinished
    tmpMaxId = maxId
    while True:
        timeLine = t.statuses.user_timeline(screen_name=screenName, count=count, max_id=tmpMaxId)
        prevMaxId = 0
        for tweetsIdx, tweet in enumerate(timeLine):
            tmpMaxId = tweet["id"]
            addArticleTitles(tweet)
            print("totalIdx = {}, prevMaxId = {}, maxId = {}, title = {}\n".format(totalIdx, prevMaxId, tmpMaxId, trendArticleTitles[totalIdx]["articleTitle"]))
            if prevMaxId == 0 and totalIdx % 200 != 0:
                isFinished = True
                break
            prevMaxId = tmpMaxId
            totalIdx += 1
        if isFinished:
            print("Finished collecting {} qiita_trend_titles.".format(totalIdx))
            break

def addArticleTitles(tweet):
    global trendArticleTitles
    tmpTitle = re.sub(r"(https?|ftp)(:\/\/[-_\.!~*\'()a-zA-Z0-9;\/?:\@&=\+\$,%#]+)", "", tweet["text"]) # ツイート内のURLを除去
    tmpTitle = ''.join(s for s in tmpTitle if s not in emoji.UNICODE_EMOJI)
    articleTitle = tmpTitle[:len(tmpTitle)-1] # 末尾の半角スペースを除去
    datum = {"articleTitle": articleTitle}
    trendArticleTitles.append(datum)
```

### 通常の記事

バズっていない通常の記事のタイトルについては、Qiita APIを用いて取得します。
こちらでは9450件のデータが集まり、トレンド記事タイトルと同様、jsonファイルにダンプしました。

※実は少々問題が起きていました※
新しい記事から順番に取得していったところ、スパムアカウントによる投稿記事まで含まれる始末・・・。
先頭付近のページからは収集しないようにコードを組み直してやり過ごすことができました。

```python
articleTitles = []
idx = 0
print("Starting collecting article titles...")
for page in range(3, 101):
    # スパムアカウントによる記事を除外するため、序盤のページは除外
    params = {"page": str(page), "per_page": str(per_page)}
    response = requests.get(url, headers=headers, params=params)
    resJson = response.json()
    for article in resJson:
        if article.get("likes_count") < notBuzzThreshold:
            title = article.get("title")
            articleTitles.append({"articleTitle": title})
            print("{}th article title = {}, url = {}".format(idx, title, article["url"]))
            idx += 1
print("Finished collecting {} qiita_article_titles.".format(idx))
```

## 記事タイトルのデータセットを1ファイルに結合

まず、上記で収集した2種類の記事タイトルデータを読み込みます。
トレンド記事かどうかのフラグを追加しつつ、1個のデータのまとまりに仕上げていきます。
念のため、結合したデータの中身をシャッフルしておきます。
※後述のデータ分割処理で、ランダムに分割しているので不要かもしれない...。

こちらでも最後にjsonファイルのダンプを行い、データ収集は終わりとなります。

```python
mergedData = []
for datum in trendData:
    mergedData.append({
        "articleTitle": datum["articleTitle"],
        "isTrend": 1
    })
for datum in normalData:
    mergedData.append({
        "articleTitle": datum["articleTitle"],
        "isTrend": 0
    })

# 結合結果の順序をシャッフル
random.shuffle(mergedData)
print("Finished shuffling 'Merged Article Titles'.")
```

## スパム検知でまずは練習

ナイーブベイズを使って推定モデルを構築しようとしますが、何から作っていけば良いのかが中々分かりませんでした。
そのため、ナイーブベイズ自体を復習・ナイーブベイズでスパム検知の実装をしている記事を試し、本実装の前に感覚を掴むようにしました。

### ナイーブベイズ - 勉強

* [Chap2_SpamDetection.md](https://github.com/13o-bbr-bbq/machine_learning_security/blob/master/Security_and_MachineLearning/Chap2_SpamDetection.md)
実例を用いて説明してあり、「そもそもナイーブベイズってどういうものだったか？」といった箇所を確認することができました。

* [[WIP]単純ベイズ分類器がまったく単純じゃないので入門](https://hogehuga.com/post-563/)
数式の色が表れた記事で、どういう計算がなされるのかも確認できました。

### ナイーブベイズ - スパム検知で練習

ナイーブベイズについて大雑把に勉強できたので、実装の練習に移りました。
↓に沿って進めていきました。
[機械学習 〜 迷惑メール分類（ナイーブベイズ分類器） 〜](https://qiita.com/fujin/items/50fe0e0227ef8457a473)

※ここで使用したデータセット：[kaggle: SMS Spam Collection Dataset](https://www.kaggle.com/uciml/sms-spam-collection-dataset)
参考になりそうな実装がKernelsにも多くありますが、今回は十分に導入しきれませんでした...。

## Qiita記事のデータセットに置き換え

ナイーブベイズの感覚を掴んだところで、いよいよ本題に入ります。
練習で活用した記事の実装から改変していった部分について書いていきます。

### MeCab, ipadic-NEologdをインストール

スパム検知のデータセットは英語なのでそのままscikit-learnにぶん投げられますが、Qiita記事タイトルはそうは行きません。
最初にMeCabとipadic-NEologdを入れ、日本語もうまく単語分割できるようにします。
（一応CountVectorizerで分割の結果は出ましたが、不自然でした）

↓のサイトを主に参考にさせていただきました。

* [PythonとMeCabで形態素解析(on Windows)](https://qiita.com/menon/items/f041b7c46543f38f78f7)
* [WindowsでNEologd辞書を比較的簡単に入れる方法](https://qiita.com/zincjp/items/c61c441426b9482b5a48)  
Windows10のコマンドプロンプトとGit Bashで交互に実行していきました。  
※パスにチルダが入っているコマンドについては[こちら](https://oshiete.goo.ne.jp/qa/1246439.html)  
→うまくできず＆Windowsに直接入れると使いづらいので、WSL経由でインストールすることにしました。
* [ubuntu 18.10 に mecab をインストール](https://qiita.com/ekzemplaro/items/c98c7f6698f130b55d53)  
[NEologd 辞書 を Windows 用 MeCab に導入して Python で使う方法](https://qiita.com/ChenZheChina/items/42f1fcc763e88cb02cca)
「Windowsで機械学習やっているのか...」と大学時代に言われてしまう時がありましたが、やっぱりLinuxだと環境作りやすいなと改めて感じました。

### モデル構築

スパム検知の練習の実装から、以下を追加しました。

* ipadic-NEologdを単語分割で使うように変更
* ストップワードを除去  
日本語のストップワードとして、[Slothlib](http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt)を利用しました。
* 絵文字を除去
* 諸々の正規化処理  
[先人の便利なライブラリneologdn](https://ohke.hateblo.jp/entry/2019/02/09/141500)を使わせていただきました。非常に便利。

```python
def getStopWords():
    stopWords = []
    with open("./datasets/Japanese.txt", mode="r", encoding="utf-8") as f:
        for word in f:
            if word != "\n":
                stopWords.append(word.rstrip("\n"))
    print("amount of stopWords = {}".format(len(stopWords)))
    return stopWords

def removeEmoji(text):
    return "".join(ch for ch in text if ch not in emoji.UNICODE_EMOJI)

stopWords = getStopWords()
tagger = MeCab.Tagger("mecabrc")
def extractWords(text):
    text = removeEmoji(text)
    text = neologdn.normalize(text)
    words = []
    analyzedResults = tagger.parse(text).split("\n")
    for result in analyzedResults:
        splittedWord = result.split(",")[0].split("\t")[0]
        if not splittedWord in stopWords:
            words.append(splittedWord)
    return words
```

CountVectorizerの引数analyzerに単語分割のメソッドを渡してやると、日本語もうまく分割してくれるようです。凄い。

```python
vecCount = CountVectorizer(analyzer=extractWords, min_df=3)
```

# 実行結果

予測用のテキストとして`"アプリをリリースしてみた"`, `"Unityチュートリアル"`, `"Gitコマンドメモ"`の3つを用意しました。
"アプリをリリースしてみた"が「バズりそう」な想定です。

## analyzer指定無しのCountVectorizer

明らかに単語数が少ないですね。正常に分割できていない感じがします。

```python
word size:  1016
word content:  {'から': 809, 'ms': 447, 'nginx': 464, 'django': 232, 'intellij': 363}
Train accuracy = 0.771
Test accuracy = 0.747
[0 0 0]
```

## MeCabのNEoglodを形態素解析器に指定

単語は分割できていそうですが、単語数が多い・・・。
分類は一応想定通りとなりました。

```python
word size:  3870
word content:  {'から': 1696, 'MS': 623, 'Teams': 931, 'に': 1853, '通知': 3711}
Train accuracy = 0.842
Test accuracy = 0.783
[1 0 0]
```

## ストップワード、絵文字を除去

単語数が減り、テストデータに対する精度がごくわずかに上昇。
前処理の大切さを感じました。

```python
word size:  3719
word content:  {'MS': 623, 'Teams': 931, 'に': 1824, '通知': 3571, 'する': 1735}
Train accuracy = 0.842
Test accuracy = 0.784
[1 0 0]
```

## 諸々の正規化処理を追加

訓練データに対する精度はわずかに落ちましたが、その分テストデータに対する精度が上がりました。
更に、分類の確率を表示し忘れていたので、ここで表示。
バズりと想定しているテキストは、思ったより高い確率で正直驚きました。
（もっと色々なテキストで試さないと信頼性が薄いですが・・・）

```python
word size:  3700
word content:  {'MS': 648, 'Teams': 955, 'に': 1838, '通知': 3583, 'する': 1748}
[1 0 0]
[[0.23452364 0.76547636]
 [0.92761086 0.07238914]
 [0.99557625 0.00442375]]
Train accuracy = 0.841
Test accuracy = 0.785
```

# 考察・課題等

今回は誤差の範囲内の精度の変化に留まってしまったように見受けられました。
NEologdに含まれている最低限の用語しか保証することができなかったので、専門用語のベクトル化までカバーすれば精度が改善できるのではと思いました。
あとは、TF-IDF等で記事タイトル・記事コンテンツから重要な語を抽出し、それを活用しても精度が向上しそうです。