nikkie-ftnextの日記

イベントレポートや読書メモを発信

ニュースを分類するMLP(keras製)をpytorchで動くように書き直そう [後編]

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
週次ブログ駆動開発、「自然言語処理のタスクをするkeras(tensorflow)製のモデルをpytorchでも書いてみる」の後編です。
前編はこちら:

更新履歴

  • 2020/04/12 末尾のグラフの直後に改行を追加して体裁修正

続・kerasからtorchへ、しかし ...!

前編から再掲しますが、以下の方針で書いています:

  • ロイター通信のデータと全く同じデータはtorchにはなさそうなので、データのロードはkerasを使用
  • 2層のMLPを作るところをtorchで書き換え

スクリプト全容

import numpy as np
from tensorflow.keras.datasets import reuters
import torch
import torch.optim as optim
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data.dataset import random_split

from keras_mlp import TokenizePreprocessor


np.random.seed(42)
torch.manual_seed(1234)

MAX_WORDS = 1000
DROPOUT = 0.5
BATCH_SIZE = 32
EPOCHS = 5


def convert_to_torch_tensors(texts, labels):
    torch_tensors = []
    for text, label in zip(texts, labels):
        text_tensor = torch.tensor(text)
        torch_tensor = (label, text_tensor)
        torch_tensors.append(torch_tensor)
    return torch_tensors


class MLPNet(nn.Module):
    def __init__(self, max_words, number_of_classes, drop_out):
        super(MLPNet, self).__init__()
        self.fc1 = nn.Linear(max_words, 512)
        self.fc2 = nn.Linear(512, number_of_classes)
        self.dropout1 = nn.Dropout(drop_out)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout1(x)
        return F.softmax(self.fc2(x), dim=1)


(x_train, y_train), (x_test, y_test) = reuters.load_data(num_words=MAX_WORDS)

tokenizer = TokenizePreprocessor.initialize_tokenizer(MAX_WORDS)
preprocessor = TokenizePreprocessor(tokenizer)
x_train = preprocessor.convert_text_to_matrix(x_train, "binary")
x_test = preprocessor.convert_text_to_matrix(x_test, "binary")

number_of_classes = np.max(y_train) + 1

train_dataset = convert_to_torch_tensors(x_train, y_train)
test_dataset = convert_to_torch_tensors(x_test, y_test)

train_length = int(len(train_dataset) * 0.9)
train_dataset, val_dataset = random_split(
    train_dataset, [train_length, len(train_dataset) - train_length]
)

train_loader = torch.utils.data.DataLoader(
    dataset=train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=1
)
val_loader = torch.utils.data.DataLoader(
    dataset=val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=1
)
test_loader = torch.utils.data.DataLoader(
    dataset=test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=1
)

device = "gpu" if torch.cuda.is_available() else "cpu"
net = MLPNet(MAX_WORDS, number_of_classes, DROPOUT).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(net.parameters())

train_loss_list, train_acc_list, val_loss_list, val_acc_list = [], [], [], []

for epoch in range(EPOCHS):
    train_loss, train_acc, val_loss, val_acc = 0, 0, 0, 0

    net.train()
    for i, (labels, text_tensors) in enumerate(train_loader):
        labels, text_tensors = labels.to(device), text_tensors.to(device)
        optimizer.zero_grad()
        # mode="binary"で指定したことでdouble(torch.float64)が渡ってきてエラーになることへの対応
        outputs = net(text_tensors.float())
        loss = criterion(outputs, labels)
        train_loss += loss.item()
        acc = (outputs.max(1)[1] == labels).sum()
        train_acc += acc.item()
        loss.backward()
        optimizer.step()
        avg_train_loss = train_loss / len(train_loader.dataset)
        avg_train_acc = train_acc / len(train_loader.dataset)

    net.eval()
    with torch.no_grad():
        for labels, texts in val_loader:
            labels, texts = labels.to(device), texts.to(device)
            outputs = net(texts.float())
            loss = criterion(outputs, labels)
            val_loss += loss.sum()
            acc = (outputs.max(1)[1] == labels).sum()
            val_acc += acc.item()
    avg_val_loss = val_loss / len(val_loader.dataset)
    avg_val_acc = val_acc / len(val_loader.dataset)

    print(
        f"Epoch [{epoch+1}/{EPOCHS}], ",
        f"Loss: {avg_train_loss:.4f}, Acc: {avg_train_acc:.4f}, ",
        f"val Loss: {avg_val_loss:.4f}, val Acc: {avg_val_acc:.4f}",
    )

    train_loss_list.append(avg_train_loss)
    train_acc_list.append(avg_train_acc)
    val_loss_list.append(avg_val_loss)
    val_acc_list.append(avg_val_acc)

net.eval()
with torch.no_grad():
    total = 0
    test_acc = 0
    for labels, texts in test_loader:
        labels, texts = labels.to(device), texts.to(device)
        outputs = net(texts.float())
        test_acc += (outputs.max(1)[1] == labels).sum().item()
        total += labels.size(0)
    print(f"test_accuracy: {100 * test_acc / total} %")

前編では、以下の2点について見ています:

  • データの準備(convert_to_torch_tensors関数)
  • モデル作成(MLPNetクラス)

学習部分のコード

for epoch in range(EPOCHS):の中に書いている部分です。

  • net.train()以降で学習用データを使った重みの更新を書き
  • net.eval()以降でバリデーションデータ1を使っての性能評価結果を書いています

kerasに比べて学習部分で書くコード量はだいぶ多く、ここが一番違うと感じました。
少しだけtorchでMNISTのコードを眺めてすぐ撤退した思い出があるのですが、「学習部分を細かく記述というkerasとの違いが、あの時は受け入れられなかったのだな」と気づきました。

学習が終わった後はテスト用データを使って性能を確認します(for文の外のnet.eval()以降の部分)。

スクリプト実行

いよいよ動かすときです。

ちなみに、上記のコードに至るまでに、スクリプトを実行した際に、

RuntimeError: Expected object of scalar type Float but got scalar type Double for argument #2 'mat1' in call to _th_addmm

が発生しました。
outputs = net(text_tensors)が原因で、.float()と型を変換する必要がありました2

それでは、動かしてみましょう。

$ python reuters/torch_mlp.py
Epoch [1/5],  Loss: 0.1055, Acc: 0.5286,  val Loss: 0.1071, val Acc: 0.5439
Epoch [2/5],  Loss: 0.1026, Acc: 0.5909,  val Loss: 0.1043, val Acc: 0.6340
Epoch [3/5],  Loss: 0.1010, Acc: 0.6399,  val Loss: 0.1042, val Acc: 0.6385
Epoch [4/5],  Loss: 0.1007, Acc: 0.6504,  val Loss: 0.1037, val Acc: 0.6552
Epoch [5/5],  Loss: 0.0998, Acc: 0.6792,  val Loss: 0.1023, val Acc: 0.6963
test_accuracy: 67.80943900267141 %

正解率がいま一つ🙄ですね。
kerasの場合は、例えばテスト用データでは79%程度の正解率が出ていました。

書き換えでの落とし穴

実は書いたコードに以下の落とし穴がありました:
torchtorch.nn.CrossEntropyLossにはsoftmaxの計算が含まれる(つまり、F.softmax(self.fc2(x), dim=1)という適用は不要)

落とし穴:torch.nn.CrossEntropyLoss

以下のQiita記事に助けられました(多謝):

class MLPNet(nn.Module):
    def __init__(self, max_words, number_of_classes, drop_out):
        # 変更なし

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout1(x)
        return self.fc2(x)  # F.softmaxを適用しない

ハマったときに調べた:Adamの学習率のデフォルト値

パラメタの指定が異なるところを比べていき、「optimizerは?」となりました。
ドキュメントを確認したところ、デフォルト値は同じでした。

再度スクリプト実行

$ python reuters/torch_mlp.py
Epoch [1/5],  Loss: 0.0461, Acc: 0.6739,  val Loss: 0.0308, val Acc: 0.7720
Epoch [2/5],  Loss: 0.0265, Acc: 0.8025,  val Loss: 0.0255, val Acc: 0.8087
Epoch [3/5],  Loss: 0.0191, Acc: 0.8527,  val Loss: 0.0231, val Acc: 0.8265
Epoch [4/5],  Loss: 0.0143, Acc: 0.8905,  val Loss: 0.0230, val Acc: 0.8309
Epoch [5/5],  Loss: 0.0115, Acc: 0.9059,  val Loss: 0.0233, val Acc: 0.8287
test_accuracy: 79.78628673196795 %

学習用データ、テスト用データでkerasの場合と同程度の正解率が出るようになりました!

f:id:nikkie-ftnext:20200104205240p:plain
(グラフはkeras_mlp.pyのplot_accuracy関数を使って描画しました)

おまけ:再現性の確保

※前掲のスクリプトには含まれています

np.random.seed(42)
torch.manual_seed(1234)

TensorFlow2系では再現性の確保のためのシードの固定を結構調べる必要があったのですが、torchでは一発で見つかりました。
Reproducibility — PyTorch master documentation

終わりに

年始で時間があったこともあり、今回のネタを選んだところ、動作するコードは半日程度で準備できました。
そこからkerasをまずQiitaにアウトプットし、torchをこのブログにと書いていったのですが、アウトプットの分量が思っていたより多くなり、時間があったからなんとか収まりました。

今回作ったモデルはシンプルなので、追加で試せそうなことはいくつもあります。
なのでこれは始まりということで、モデルの改良で引き続き手を動かしていきます。

今回のコードはリポジトリに入れました:

ブログ駆動開発2回目は以上です。
それでは、翌週のブログ駆動開発(自然言語処理編)でお会いしましょう。

試してみたい情報

この記事の下書きを書いてから公開までの間に見つけた情報です。


  1. チュートリアルにならってrandom_splitを使い、学習用データの10%をバリデーションデータとして取り分けました

  2. ref: https://stackoverflow.com/a/56741419 。この記事を書いていて思ったのですが、kerassequences_to_matrixの返り値をtensorにする際に、型を指定する必要があったのかもしれません