深層学習を使った回帰モデルでQiitaの記事名からいいね数を予測してみた

# はじめに
こんにちは。NTTドコモの白水です。

Qiitaには「いいね」機能があります。「いいね」の数が記事に対する評価になっていますし，Contributionも「いいね」数を基に算出されていることから，投稿者・閲覧者の双方にとって重要な機能のひとつだといえます。

投稿者が「いいね」やちやほやされるために記事を書いてるわけではないと思いますが，やはり，たくさんの「いいね」がもらえるに越したことはありません。
どうすれば「いいね」がたくさんもらえるでしょうか。要因は様々あると思いますが，そのひとつが記事タイトルであることは確かです。キャッチーなタイトルや，内容をよく表しているタイトルは印象が良いでしょうし，逆に，難解なタイトルや長すぎるタイトルは印象が悪いでしょう。

そこで今回は，Qiitaの投稿記事タイトルを特徴量にして，「いいね」がいくつもらえそうかを予測する回帰モデルを，[**PyTorch**](https://pytorch.org/)のチュートリアルも兼ねて実装してみました。
ちなみに本記事のタイトルは，モデルが出力したいいね予測値を参考につけました。

# 準備
[Qiitaの投稿記事からデータセット作った](https://qiita.com/dcm_chida/items/687654685dc434bdc9d4)で紹介があった，Qiitaの投稿記事データセットを使います。
データセットの構造は下記の通りです。

カラム名|説明
:--|:--
created_at|記事が作成された時間
updated_at|記事が更新された時間
id|記事ID
title|記事名
user|投稿者
likes_count|いいね数
comments_count|コメント数
page_views_count|ページビュー
url|記事URL
tags|記事に付与されたタグ

この内，必要なのは記事名といいね数だけなので，`awk`を使って抽出します。

```bash
awk 'BEGIN{FS="\t"; OFS="\t"}{print $4, $6}' qiita_data1113.tsv > title_like.tsv
```
データセットの準備ができました。

# 実装
深層学習のフレームワークはいろいろありますが，サービスイノベーション部の自然対話チームでは，自然言語処理に適したフレームワークである**PyTorch**を使っています。本記事ではPyTorchのチュートリアルを兼ねて，適宜解説を入れながら実装を進めていきます。
なお，PyTorchのバージョンは0.4.0，Pythonのバージョンは2.7を使いました。

まずは，あらかじめモジュールを読み込んだり変数を定義したりしておきます。

```Python
import codecs
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split
import numpy as np
import MeCab


MAX_WORDS = 20  # シーケンスの最大長
torch.manual_seed(0)
emb_dim = 200  # 分散表現の次元数
hidden_dim = 100  # 隠れ層の次元数
train_batch_size = 64  # 訓練時のバッチサイズ
test_batch_size = 1000  # テスト時のバッチサイズ
epochs = 3  # 学習回数
device = 'cpu'  # GPUで学習するならcudaにする
lr = .01  # 学習率
```

実装は以下の順番で進めていきます。
1. データセットの読込・加工
2. モデルの構築
3. モデルの訓練・テスト
4. 予測結果の確認

## データセットの読込・加工
先ほど作成した，記事タイトルといいね数の対データを読み込む関数を準備します。行数もそれほど多くないのでメモリに全部載せてしまいます。

```Python
def load_data(f):
    titles, likes = [], []
    with codecs.open(f, 'r', 'utf-8') as rf:
        for i, line in enumerate(rf):
            if i == 0:  # headerなのでスキップする
                continue
            title, like = line.strip().rsplit('\t', 1)
            like = int(like)
            titles.append(title)
            likes.append(like)
    return titles, likes

titles, likes = load_data('title_like.tsv')
```

次に，全ての記事名を分かち書きして，語彙の集合 (set型) を作り，インデックスを付与します。語彙の集合とインデックスは，単語の埋込表現（語彙数×次元数）を作成するときのルックアップテーブルとして利用します。分かち書きにはMeCabを使いました。今回のデータセットでは，最終的な語彙数は119,942語でした。
最後に，データセットを訓練データとテストデータに分割します。分割割合は9:1にしました。

```Python
# タイトルを分かち書きして語彙セットを作成
m = MeCab.Tagger('-Owakati')  # 分かち書き
words = {
    word.lower() for title in titles for word in m.parse(
        title.encode('utf-8')
        ).decode('utf-8').strip().split(' ')
    }
# 語彙セットからインデックスを作成
# 0は未知語に割り当てたいので，インデックスは1から開始
word2idx = {x: i for i, x in enumerate(words, 1)}

# 訓練用とテスト用にデータセットを9:1で分割
X_train, X_test, y_train, y_test = train_test_split(
    titles, likes, test_size=0.1, random_state=0
    )
```

### Dataset
PyTorchではDataset/DataLoaderというユーティリティが用意されており，モデルに訓練データ（テストデータ）を渡す上で必要な，データの前処理やバッチ化，並列処理などをまとめることができます。

まずは前処理をしてくれるDatasetクラスを作成します。抽象クラスであるDatasetを継承し，`__len__`メソッドと`__getitem__`メソッドをオーバーライドします。[PyTorchの公式チュートリアル](https://pytorch.org/tutorials/beginner/data_loading_tutorial.html)によれば，それぞれの役割は下記です。

- `__len__`: `len(dataset)`でデータセットのサイズが返って来るようになる
- `__getitem__`: `dataset[i]`でi番目のサンプルが返って来るようになる

`__getitem__`はnumpy配列を返すようにしておきましょう。また，データセットのいいね数をそのまま使うと不便なので，対数化します。関数の最後で，numpy配列を辞書にまとめて返却していますが，ばらばらに返却しても大丈夫です。

```Python
class TitlesDataset(Dataset):
    def __init__(self, titles, likes, token2idx):
        self.titles = titles
        self.likes = likes
        self.token2idx = token2idx


    def __len__(self):
        return len(self.titles)


    def __getitem__(self, idx):
        # 記事に含まれる単語をインデックスに変換しnumpy配列にする
      	title = np.asarray(
            self.pad_sequence(
                # 未知語（語彙セットにない語）にはインデックスとして0を割り当てる
                [self.token2idx[x.lower()] if x.lower() in self.token2idx else 0 for x in m.parse(
                        self.titles[idx].encode('utf-8')).decode('utf-8').strip().split(' ')]
                )
            )
        # いいね数をnumpy配列にする
        like = np.asarray([self.likes[idx]], dtype=np.float32)
        # いいね数を対数化する
        log_like = np.log1p(like)
        # それぞれのnumpy配列を辞書型の変数sampleに格納する
        sample = {
            'title': title,
            'like': like,
            'log_like': log_like,
            }
        return sample


    def pad_sequence(self, title):
        if len(title) > MAX_WORDS:  # 単語数があらかじめ決めておいたシーケンス長より多ければ切り詰める
            title = title[:MAX_WORDS]
      	else:  # 単語数があらかじめ決めておいたシーケンス長より少なければ0埋めする
            title += [0] * (MAX_WORDS - len(title))
        return title
```

インデキシングして`print`してみると，バッチサイズ1のサンプルが返ってきます。
ちなみに、PyTorchは`print`を使ったデバッグがしやすいので，テンソルの中身をちょっと見たいというときや，学習途中の結果を確認したいというときは，`print`を入れていくことで実現できます。

```Python
train_dataset = TitlesDataset(X_train, y_train, word2idx)
print train_dataset[0]
#
# {
#     'log_like': array([1.0986123], dtype=float32),
#     'like': array([2.], dtype=float32),
#     'title': array(
#         [31247, 16504, 62280, 61930, 0, 0, 0,
#          0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
#         )
# }
```

### DataLoader
先ほど作成したDatasetを引数にして，バッチ化，データのシャッフル，並列処理によるデータ読込みなどをやってくれるイテレータがDataLoaderです。訓練時・テスト時には，DataLoaderからバッチを取得してモデルに渡します。引数はいろいろありますが，基本的には`batch_size`と`num_workers`と`shuffle`を設定すれば十分だと思います。

訓練用のDataLoaderに訓練用のDataset，テスト用のDataLoaderにテスト用のDatasetを渡します。

```Python
train_loader = DataLoader(
    TitlesDataset(X_train, y_train, word2idx),
    batch_size=train_batch_size,
    shuffle=True,
    num_workers=4
    )
test_loader = DataLoader(
    TitlesDataset(X_test, y_test, word2idx),
    batch_size=test_batch_size,
    shuffle=True,
    num_workers=4
    )
```

## モデルの構築
今回はCNNを使って，いいね数を予測するモデルを構築します。自然言語処理でCNNというと，文字レベルの特徴量を入力する印象ですが，形態素レベルの特徴量の方が良い結果だったので，今回は形態素レベルのCNNにします。途中で1次元のフィルター (Conv1d) を4種類かけていますが，これが言語処理でいう単語N-gramを見ていることに相当します。

PyTorchでは以下のように，`__init__`に必要なレイヤーを書き，`forward`でレイヤーを組み合わせるようにして順伝播のネットワークを作ります。
途中で`transpose`して軸を交換しているのは，テンソルの次元をフィルターの入力に合わせるためです。PyTorchでは他にも，`squeeze/unsqueeze`を使ってテンソルの次元を足し引きするなど，次元を操作することが多い気がします。

```Python
class CNN(nn.Module):
    def __init__(self, len_tokens, emb_dim, hidden_dim):
        super(CNN, self).__init__()
        # 語彙集合の大きさに0（未知語）を加えた数が語彙数
        self.emb = nn.Embedding(len_tokens + 1, emb_dim, padding_idx=0)  # 埋込層
        self.conv_2 = nn.Sequential(  # 2-gramのフィルター
            nn.Conv1d(emb_dim, hidden_dim, 2, padding=1),
            nn.MaxPool1d(2)
            )
        self.conv_3 = nn.Sequential(  # 3-gramのフィルター
            nn.Conv1d(emb_dim, hidden_dim, 3, padding=1),
            nn.MaxPool1d(3)
            )
        self.conv_4 = nn.Sequential(  # 4-gramのフィルター
            nn.Conv1d(emb_dim, hidden_dim, 4, padding=1),
            nn.MaxPool1d(4)
            )
        self.conv_5 = nn.Sequential(  # 5-gramのフィルター
            nn.Conv1d(emb_dim, hidden_dim, 5, padding=1),
            nn.MaxPool1d(5)
            )
        self.fc1 = nn.Linear(hidden_dim * 23, 64)  # 全結合層
        self.fc2 = nn.Linear(64, 1)


    def forward(self, x):
        emb = self.emb(x)  # batch_size * sequence_length * embedding_size
        emb = emb.transpose(1, 2)  # batch_size * embedding_size * sequence_length
        x = torch.cat(
            (self.conv_2(emb), self.conv_3(emb), self.conv_4(emb), self.conv_5(emb), ), 2
            )
        x = x.view(-1, x.size(1) * x.size(2))  # batch_size * (embedding_size * 23)
        x = self.fc1(x)
        x = F.dropout(x, training=self.training)
        x = self.fc2(x)
        return x
```

## モデルの訓練・テスト
for文を使って，作成したDataLoaderからミニバッチを取り出していきます。

PyTorchでは，`loss`の計算やパラメータの更新，評価結果の表示などを自分で書く必要があります。
`train`関数・`test`関数の処理の流れは（損失計算で凝ったことをしなければ）概ね以下のようになると思います。

1. バッチから入力特徴量と正解データを取り出す
1. GPUを使う場合はGPUにテンソルを載せる
1. オプティマイザを初期化する（訓練時のみ）
1. モデルに特徴量を入力して予測を得る
1. 予測と正解データを損失関数に渡して損失を計算する
1. 勾配を計算する（訓練時のみ）
1. パラメータを更新する（訓練時のみ）

はじめは面倒に感じましたが，特徴量をモデルに入力する→モデルの予測値と正解値のズレ（損失）を計算する→勾配を計算してパラメータを更新する→……と意識しながら書くと，ニューラルネットワークへの理解がより深まります。

```Python
# 訓練
def train(model, device, train_loader, optimizer, epoch, log_interval=100):
    model.train()
    for batch_idx, batch_sample in enumerate(train_loader):
        titles = batch_sample['title']
        likes = batch_sample['like']
        log_likes = batch_sample['log_like']
        titles, likes, log_likes = titles.to(device), likes.to(device), log_likes.to(device)
        optimizer.zero_grad()  # オプティマイザの初期化
        output = model(titles)  # 予測
        loss = criterion(output, log_likes)  # 損失の計算
        loss.backward()  # 勾配の計算
        optimizer.step()  # パラメータの更新
        if batch_idx % log_interval == 0:
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
                epoch, batch_idx * len(titles), len(train_loader.dataset),
                100. * batch_idx / len(train_loader), loss.item()))

# テスト
def test(model, device, test_loader):
    model.eval()
    test_loss = 0
    with torch.no_grad():
        for batch_sample in test_loader:
            titles = batch_sample['title']
            likes = batch_sample['like']
            log_likes = batch_sample['log_like']
            titles, likes, log_likes = titles.to(device), likes.to(device), log_likes.to(device)
            output = model(titles)  # 予測
            test_loss += criterion(output, log_likes)  # 損失計算
    test_loss /= len(test_loader.dataset)
    print('\nTest set: Average loss: {:.4f})\n'.format(test_loss))
```

作成した`train`関数と`test`関数を使って，モデルの訓練・テストを行います。

```Python
model = CNN(len(words), emb_dim, hidden_dim)  # モデルのインスタンスを作成
# 学習済みモデルを読み込むときは↓のコメントアウトを解除する
# model.load_state_dict(torch.load('./weight.pth'))
model = model.to(device)
criterion = nn.MSELoss()  # 最小二乗誤差を損失関数に設定
optimizer = optim.Adam(model.parameters(), lr=lr)  # オプティマイザはAdamを設定
for epoch in range(1, epochs + 1):  # 指定したエポック分，訓練とテストを繰り返す
    train(model, device, train_loader, optimizer, epoch)  # 訓練
    test(model, device, test_loader)  # テスト
torch.save(model.state_dict(), './weight.pth')  # 学習したモデルの保存
```

# 予測結果の確認
いよいよ，訓練したモデルを使って，もらえるいいね数を予測してみます。モデルの予測値として，対数化されたいいね数が返って来るので，逆対数に変換してあげます。モデルの入力は，この記事のタイトル候補です。

```Python
# 予測
def predict(model, device, predict_loader):
    model.eval()
    with torch.no_grad():
        for batch_sample in predict_loader:
            titles = batch_sample['title']
            output = model(titles)
    return np.exp(output.numpy()) - 1  # 逆対数化

# タイトル候補を1行ずつ並べたファイルを読み込んで入力データとする
with codecs.open('candidates.txt', 'r', 'utf-8') as rf:
    X_predict = [line.strip()for line in rf]
    y_predict = [0 for i in range(len(X_predict))]  # dummy
    predict_loader = DataLoader(  # 予測用のDataLoader
        TitlesDataset(X_predict, y_predict, word2idx),
        batch_size=len(X_predict),
        shuffle=False,
        num_workers=4
        )
    for x, y in sorted(zip(X_predict, predict(model, device, predict_loader)), key=lambda (x, y): y, reverse=True):
        print(u'「{}」がもらえるいいね数は{:.2f}です'.format(x, y[0]))
```

結果を見てみましょう。いいね数の予測値が高い順にソートしています。

```
「記事名からいいね数を予測してみた」がもらえるいいね数は46.81です
「深層学習を使った回帰モデルでQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は38.95です
「深層学習を使ったモデルでQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は27.83です
「深層学習を使ってQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は24.28です
「Deep Learningを使ってQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は14.99です
「記事名からいいね数を予測する回帰モデルを深層学習で作ってみた」がもらえるいいね数は12.60です
「PyTorchを使った回帰モデルでQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は10.77です
「深層学習使ってみた」がもらえるいいね数は10.71です
「深層学習でQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は9.88です
「ディープラーニングを使ってQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は6.62です
「PyTorchを使ってQiitaの記事名からいいね数を予測してみた」がもらえるいいね数は5.00です
「Qiitaの記事名からいいね数を予測してみた」がもらえるいいね数は3.68です
「Qiitaでいいねがつきそうな記事名を深層学習で予測してみた」がもらえるいいね数は2.71です
```

モデルによると，「記事名からいいね数を予測してみた」がいちばんいいねがもらえるらしいのですが，シンプルすぎる記事名はよくないと思うので，次点でいいねがたくさんもらえそうな「深層学習を使った回帰モデルでQiitaの記事名からいいね数を予測してみた」を今回のタイトルにしました。
何となく，「深層学習」から始まるタイトルはいいねをもらえる傾向にあるようです。

# おわりに
PyTorchのチュートリアルを兼ねて，記事名からいいね数を予測する深層学習モデルを組んでみました。このモデルを使えば，「この記事のポテンシャルは20いいねだな」とか「100いいね欲しいからタイトルはこれにしよう」とか，もらえるいいね数を投稿前に確認できます。これで今夜も安心して熟睡できますね。

また，PyTorchは，Kerasと比べると書くべき内容が多く面倒に感じるところもありますが，細かいところまで手が届く印象でした。自然言語処理の深層学習モデルがPyTorchで実装されていたりするので，自然言語処理に携わる方なら読み書きできて損はないと思います。