PyTorchを使ってLSTMで文章分類を実装してみた（バッチ化対応ver）

## 目次
本記事はPyTorchを使って自然言語処理 $\times$ DeepLearningをとりあえず実装してみたい、という方向けの入門講座になっております。以下の順番で読み進めていただくとPyTorchを使った自然言語処理の実装方法がなんとなくわかった気になれるかもしれません。

1. [PyTorchを使ってLSTMで文章分類を実装してみた](https://qiita.com/m__k/items/841950a57a0d7ff05506) 
2. [PyTorchを使ってLSTMで文章分類を実装してみた（バッチ化対応ver）](https://qiita.com/m__k/items/db1a81bb06607d5b0ec5) ←イマココ
3. [PyTorchでSeq2Seqを実装してみた](https://qiita.com/m__k/items/b18756628575b177b545)
4. [PyTorchでAttention Seq2Seqを実装してみた](https://qiita.com/m__k/items/646044788c5f94eadc8d)
5. [PyTorchのBidirectional LSTMのoutputの仕様を確認してみた](https://qiita.com/m__k/items/78a5125d719951ca98d3)
6. [PyTorchでSelf Attentionによる文章分類を実装してみた](https://qiita.com/m__k/items/98ff5fb4a4cb4e1eba26)
7. [PyTorchで日本語BERTによる文章分類＆Attentionの可視化を実装してみた](https://qiita.com/m__k/items/e312ddcf9a3d0ea64d72)



## はじめに

[前回の記事](https://qiita.com/m__k/items/841950a57a0d7ff05506)でPytorchでLSTMを使って文章分類（ニュース記事のタイトルのカテゴリ分類）を実装しました。

その際、バッチ化はひとまず置いといて（バッチサイズ=1）で実装していましたが、今回はバッチ化対応したので、LSTMのバッチ化がわからないなどバッチ化で躓いている人のために参考になればと思い、ここに実装例を記載いたします。

（色々実装が間違っている可能性があるので、お気づきの方はぜひともご指摘いただけたらと思います。）

## Google Colab用の準備
バッチ化することの最大のメリットはやはりGPUによる爆速化かと思います。私の手元にはGPUマシンがないので、[Google Colab](https://colab.research.google.com)のGPUを使います。

### データのアップロード
前回の記事と同様の問題（livedoorニュースコーパスの記事のタイトル分類）を扱います。
まずはlivedoorニュースコーパスデータをcolabから読み込めるようにするための準備が入ります。
colabを開いて、以下を実行します。

```python
from google.colab import drive
drive.mount('/content/drive')
```

すると、こんなような出力になると思います。
<img width="472" alt="スクリーンショット 2019-09-01 14.43.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/240999/66399187-4a7f-7b7e-de44-3e776929ba66.png">

`Go to this in a browser` のURLをクリックしてGoogleアカウントでログインします。
<img width="482" alt="スクリーンショット 2019-09-01 14.45.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/240999/52bbbe89-f756-95c7-a587-ae02781aa3df.png">

黒くしている部分にコードが記載されているので、それをコピーして、上の`Enter your authorization code:`にペーストします。
すると、Google driveがマウントされ、colab上からGoogle driveのファイルへアクセスできます。

livedoorニュースコーパスのデータをGoogle driveに置いておきましょう。

### MeCabを使えるようにする
こちらにcolabでMeCabを使えるようにするための手順の記載があります。
[ColaboratoryでMeCabを使えようにする。](https://qiita.com/pytry3g/items/897ae738b8fbd3ae7893)

上記記事のように以下のコマンドをcolab上で実行しましょう。

```python
# MeCabをcolabで使えるようにする
!apt install aptitude
!aptitude install mecab libmecab-dev mecab-ipadic-utf8 git make curl xz-utils file -y
!pip install mecab-python3
```

colab上でMeCabが使えるようになります。（以下でエラーでなければOK）

```python
import MeCab
```

## バッチ化
LSTMのインプットの形式は前回の記事でも言及したように`文章の長さ × バッチサイズ × ベクトル次元数`の３次元テンソルでした。

実際のデータ（ニュース記事のタイトル）の長さ（厳密には形態素の数）は異なりますが、データをバッチ化してまとめてLSTMに流すために文章の系列の長さを揃える必要があります。


系列の長さを揃えるために単語リストに新しく`<pad>`（単語ID＝0）を追加して短い文章を0パディングします。
詳細はソースコードを見たほうがわかりやすいと思うので、いきなり全ソースを載せつつ、[前回の記事](https://qiita.com/m__k/items/841950a57a0d7ff05506)と比べてバッチ化対応するために変更した部分に言及してきます。

### データをDataFrameにまとめる
Google driveのデータを読み込むようにしたところ以外は変更なし

```python
import os
from glob import glob
import pandas as pd
import linecache

drive_dir = "drive/My Drive/Colab Notebooks/"

categories = [name for name in os.listdir(drive_dir + 'text') if os.path.isdir(drive_dir + "text/" +name)]
print(categories)

datasets = pd.DataFrame(columns=["title", "category"])
for cat in categories:
    path = drive_dir + "text/" + cat + "/*.txt"
    files = glob(path)
    for text_name in files:
        title = linecache.getline(text_name, 3)
        s = pd.Series([title, cat], index=datasets.columns)
        datasets = datasets.append(s, ignore_index=True)

```

### 形態素解析エンジン定義
前回と変更なし

```python
import MeCab
import re

tagger = MeCab.Tagger("-Owakati")

def make_wakati(sentence):
    sentence = tagger.parse(sentence)
    sentence = re.sub(r'[0-9０-９a-zA-Zａ-ｚＡ-Ｚ]+', " ", sentence)
    sentence = re.sub(r'[\．_－―─！＠＃＄％＾＆\-‐|\\＊\“（）＿■×+α※÷⇒—●★☆〇◎◆▼◇△□(：〜～＋=)／*&^%$#@!~`){}［］…\[\]\"\'\”\’:;<>?＜＞〔〕〈〉？、。・,\./『』【】「」→←○《》≪≫\n\u3000]+', "", sentence)
    wakati = sentence.split(" ")
    wakati = list(filter(("").__ne__, wakati))
    return wakati
```

### 単語IDの辞書を定義
単語IDの辞書に新しく`<pad>`を追加

```python
word2index = {}
# 系列を揃えるためのパディング文字列<pad>を追加
# パディング文字列のIDは0とする
word2index.update({"<pad>":0})

for title in datasets["title"]:
    wakati = make_wakati(title)
    for word in wakati:
        if word in word2index: continue
        word2index[word] = len(word2index)
print("vocab size : ", len(word2index))
```

### 系列の長さを揃えてバッチでまとめる

```python
from sklearn.model_selection import train_test_split
import random
from sklearn.utils import shuffle

cat2index = {}
for cat in categories:
    if cat in cat2index: continue
    cat2index[cat] = len(cat2index)

def sentence2index(sentence):
    wakati = make_wakati(sentence)
    return [word2index[w] for w in wakati]
    
def category2index(cat):
    return [cat2index[cat]]

index_datasets_title_tmp = []
index_datasets_category = []

# 系列の長さの最大値を取得。この長さに他の系列の長さをあわせる
max_len = 0
for title, category in zip(datasets["title"], datasets["category"]):
  index_title = sentence2index(title)
  index_category = category2index(category)
  index_datasets_title_tmp.append(index_title)
  index_datasets_category.append(index_category)
  if max_len < len(index_title):
    max_len = len(index_title)

# 系列の長さを揃えるために短い系列にパディングを追加
# 後ろパディングだと正しく学習できなかったので、前パディング
index_datasets_title = []
for title in index_datasets_title_tmp:
  for i in range(max_len - len(title)):
    title.insert(0, 0) # 前パディング
#     title.append(0)　# 後ろパディング
  index_datasets_title.append(title)

train_x, test_x, train_y, test_y = train_test_split(index_datasets_title, index_datasets_category, train_size=0.7)

# データをバッチでまとめるための関数
def train2batch(title, category, batch_size=100):
  title_batch = []
  category_batch = []
  title_shuffle, category_shuffle = shuffle(title, category)
  for i in range(0, len(title), batch_size):
    title_batch.append(title_shuffle[i:i+batch_size])
    category_batch.append(category_shuffle[i:i+batch_size])
  return title_batch, category_batch
```

### モデル定義
パディング文字列<pad>ももちろん埋め込む必要があるわけですが、`<pad>`は0ベクトルで埋め込み、学習の妨げにならないようにする(?)ために、`nn.Embedding()`にて`padding_idx=0`を追加しています。
LSTMを定義する際、`batch_first=True`を指定すると、LSTMのインプットの形式が`バッチサイズ × 文章の長さ × ベクトル次元数`になります。こうしたほうが次元を操作する際にわかりやすいと思います。


```python
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

# GPUを使うために必要
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class LSTMClassifier(nn.Module):
    def __init__(self, embedding_dim, hidden_dim, vocab_size, tagset_size):
        super(LSTMClassifier, self).__init__()
        self.hidden_dim = hidden_dim
        # <pad>の単語IDが0なので、padding_idx=0としている
        self.word_embeddings = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
        # batch_first=Trueが大事！
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        self.hidden2tag = nn.Linear(hidden_dim, tagset_size)
        self.softmax = nn.LogSoftmax()

    def forward(self, sentence):
        embeds = self.word_embeddings(sentence)
        #embeds.size() = (batch_size × len(sentence) × embedding_dim)
        _, lstm_out = self.lstm(embeds)
        # lstm_out[0].size() = (1 × batch_size × hidden_dim)
        tag_space = self.hidden2tag(lstm_out[0])
        # tag_space.size() = (1 × batch_size × tagset_size)

        # (batch_size × tagset_size)にするためにsqueeze()する
        tag_scores = self.softmax(tag_space.squeeze())
        # tag_scores.size() = (batch_size × tagset_size)

        return tag_scores

# 単語の埋め込み次元数上げた。精度がそこそこアップ！ハイパーパラメータのチューニング大事。
EMBEDDING_DIM = 200
HIDDEN_DIM = 128
VOCAB_SIZE = len(word2index)
TAG_SIZE = len(categories)
# to(device)でモデルがGPU対応する
model = LSTMClassifier(EMBEDDING_DIM, HIDDEN_DIM, VOCAB_SIZE, TAG_SIZE).to(device)
loss_function = nn.NLLLoss()
# SGDからAdamに変更。特に意味はなし
optimizer = optim.Adam(model.parameters(), lr=0.001)
```

### 学習
1epoch毎に全バッチを学習させます。バッチごとに逆伝搬してパラメータ更新させてます。

```python
losses = []
for epoch in range(100):
    all_loss = 0
    title_batch, category_batch = train2batch(train_x, train_y)
    for i in range(len(title_batch)):
        batch_loss = 0

        model.zero_grad()

        # 順伝搬させるtensorはGPUで処理させるためdevice=にGPUをセット
        title_tensor = torch.tensor(title_batch[i], device=device)
        # category_tensor.size() = (batch_size × 1)なので、squeeze()
        category_tensor = torch.tensor(category_batch[i], device=device).squeeze()

        out = model(title_tensor)

        batch_loss = loss_function(out, category_tensor)
        batch_loss.backward()
        optimizer.step()

        all_loss += batch_loss.item()
    print("epoch", epoch, "\t" , "loss", all_loss)
    if all_loss < 0.1: break
print("done.")
```

### 予測
バッチ毎にまとめて予測。
前回と比べて精度が上がっているのは単語の埋め込み次元数を上げたからだと思われます。

```python
test_num = len(test_x)
a = 0
with torch.no_grad():
    title_batch, category_batch = train2batch(test_x, test_y)
    
    for i in range(len(title_batch)):
        title_tensor = torch.tensor(title_batch[i], device=device)
        category_tensor = torch.tensor(category_batch[i], device=device)
        
        out = model(title_tensor)
        _, predicts = torch.max(out, 1)
        for j, ans in enumerate(category_tensor):
            if predicts[j].item() == ans.item():
                a += 1
print("predict : ", a / test_num)
# predict :  0.6967916854948034
```

## 最後に
バッチ化対応の一番苦労したところはやはり次元の扱いでした。私と同じように次元数によるエラーで躓いている方は、全てのtensorの次元を逐一確認して、データの形状を細かく追ってみるのがよいと思います。

Next → [PyTorchでSeq2Seqを実装してみた](https://qiita.com/m__k/items/b18756628575b177b545)

おわり