Pythonいぬ

pythonを使った画像処理に関する記事を書いていきます

PytorchでMNISTの分類をやってみる

Pytorchで自分で定義したCNNを使って、手書き数字MNISTの分類をやってみる。特にpytorchにおけるニューラルネットの書き方と、ネットワークの学習についてソースコードを交えて説明する。

f:id:tzmi:20200223232209p:plain

ネットワークの定義

まずはネットワークの定義のやり方。pytorchではニューラルネットの構造はclassを使って定義する。ネットワークが同じ構造でも書き方は色々あって、一番シンプルかつきれいに書ける方法は以下。forward関数を一行で書けるので、nn.Sequentialで書く方法が最もシンプル。内容はLeNet-5 likeの構造にする。

from torch import nn

class cnn(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Conv2d(1, 6, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Conv2d(6, 16, kernel_size=5),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
            nn.Flatten(),
            nn.Linear(16*5*5, 120),
            nn.ReLU(inplace=True),
            nn.Linear(120,84),
            nn.ReLU(inplace=True),
            nn.Linear(84, 10),
            nn.Softmax(dim=1),
        )

        # weight init                                                                      
        for m in self.layers.children():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight)
            if isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight)

    def forward(self, x):
        return self.layers(x)

上記の書き方は特にforwardの部分がすっきり書けるところが利点。一方で①Skip connectionや並列分岐を書けないこと、②デバッグがやりにくいこと、の2つの欠点がある。

デバッグしながらやる場合は、Sequentialではなく、各レイヤに名前をつけて書いた方がいいかもしれない。こうすれば、途中のレイヤの出力をとったりできる。例えば以下の書き方も同じ意味になる。

from torch import nn

class cnn2(nn.Module):
    def __init__(self):
        super().__init__()
        self.c1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
        self.r1 = nn.ReLU(inplace=True)
        self.m1 = nn.MaxPool2d(2)
        self.c2 = nn.Conv2d(6, 16, kernel_size=5)
        self.r2 = nn.ReLU(inplace=True)
        self.m2 = nn.MaxPool2d(2)
        self.flatten = nn.Flatten()
        self.fc1 = nn.Linear(16*5*5, 120)
        self.r3 = nn.ReLU(inplace=True)
        self.fc2 = nn.Linear(120,84)
        self.r4 = nn.ReLU(inplace=True)
        self.fc3 = nn.Linear(84, 10)
        self.softmax = nn.Softmax(dim=1)

        # weight init                                                                      
        for m in self.children():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight)
            if isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight)

    def forward(self, x):
        x = self.m1(self.r1(self.c1(x)))
        x = self.m2(self.r2(self.c2(x)))
        x = self.flatten(x)
        x = self.r3(self.fc1(x))
        x = self.r4(self.fc2(x))
        x = self.softmax(self.fc3(x))
        return x

この方法だと例えば、最初のMaxpool2dの出力をforward関数のreturnの部分に書くことができる。つまり、中間層出力のshapeがどうなっているかを確認しながらネットワークを作っていくことができる。試行錯誤しながら自作ネットワークを作るときはこの方法を使うのがおすすめ。

学習と推定

Pytorchの場合、学習はforループで書く。kerasと違って、pytorchでは学習ループの中身がわかりやすいし、loss関数も定義されているものだけでなく自分で書けるので、新しいlossを作ってみたい場合も手間がかからない。ここからは特に学習のための詳細を説明する。

GPUとCPUの自動判別

まずは、GPUかCPUかの判別。pythonの1行if文で書くと以下になる。

device = 'cuda' if torch.cuda.is_available() else 'cpu'

PytorchはデフォルトだとCPUのtensorであるが、to('cuda')とすればGPUtensorが使える。CPUのtensorto('cpu')としても問題ないので、どちらでも使えるようにするという意味で上記のdeviceを定義しておく。

modelの定義

次にモデルの定義は以下のようにする。

model = cnn().to(device)

cnn()は少し前の方で定義したニューラルネットの構造。to(device)をつけるのを忘れないようにする。

Optimizerの定義

学習をするためには最適化が必要。これを行うのがoptimizerで、例えばAdamなら以下のよに定義する。

opt = torch.optim.Adam(model.parameters())

引数はmodel.parameters()で、ここでモデルのパラメータをadamのメンバにすることで、optimizerとモデルのパラメータを関連づける。

データセットの読み出し

次はデータセットの読み出し。まずはtransformsの定義。これは入力データのtorch tensor化やnormalizeを行ったり、データ拡張を行ったりするもの。torch tensorに変換して、normalizeするには以下のようにする。

transform = transforms.Compose([transforms.ToTensor(),
                                    transforms.Normalize((0.5,), (0.5,))])

単純にtorch tensorにするだけなら以下のように定義できる。

transform = transforms.ToTensor()

この関数を使うと、「範囲が[0,255]でnp.uint8型のnumpy ndarray」が「範囲が[0, 1]でtorch.float型のtorch tensor」に変換される。pytorchでは基本channel firstを使うため、channelの位置も channel last (h,w,c)から channel first (c,w,h)へ変換される。

手書き文字データセットであるMNISTはtorchvision.datasetsで呼び出せる。データセットはさらにtorch.utils.data.DataLoaderに入れると後でバッチで読み出せるようになる。

trainset = MNIST(root='./data', train=True, download=True, transform=transform)
trainloader = DataLoader(trainset, batch_size=bs, shuffle=True)

データの読み込みはDataLoaderを使わなくても自分で書いてもよい。自分で書く場合はtorch.from_numpyなどを使って、tensorに変換してからモデルに入力する。

numpyからtorch tensorへの変換は以下 tzmi.hatenablog.com

model.train()とmodel.eval()

Pytorchはtrain()eval()を使って学習と推定を切り分ける。今回使ったネットワークでは学習と推定の結果は同じになるが、resnetなどで使われるbatchnormや、pix2pixなどで使われるdropoutを含む場合はtrainevalでネットワークの挙動が変わる。これを制御するの関数が、model.train()model.eval()である。

特に論文などでtrainevalを間違えると結果が変わって大変なので、明示的に学習の前には

model = model.train() # 学習モード

推定の前には

model = model.eval() # 推定モード

と書くクセをつけておくとよい。

学習処理

学習では、(x, y)の学習データペアを得る、forward処理をして推定値yを得る、yとyを使ってlossを求める、勾配をリセットする、backward処理をする、パラメータを動かすという手順をループでまわす。コードで書くと以下のようになる。

train_iter = trainloader.__iter__() #iterのインスタンスを作る(forループで代替)
x, y = train_iter.next() # (x, y)の学習データペアを得る
y_ = model.forward(x) # forward処理をして、推定値y_を得る
loss = -torch.mean(y*torch.log(y_+eps)) # yとy_からlossを計算
opt.zero_grad() # 勾配初期化
loss.backward() # backward (勾配計算)
opt.step() # パラメータを動かす

pytorchでは、勾配はリセットされずに加算されていくので、イテレーション毎に勾配のリセット(初期化)が必要。

スクリプト全体

下記が学習スクリプト全体。学習部分はepochとバッチ計算の2重forループとなる。

import torch
from torch.utils.data import DataLoader
from torchvision import transforms
from torchvision.datasets import MNIST

if __name__ == '__main__':

    # GPU or CPUの自動判別                                                                 
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    # modelの定義                                                                          
    model = cnn().to(device)
    opt = torch.optim.Adam(model.parameters())

    # datasetの読み出し                                                                    
    bs = 128 # batch size                                                                  
    transform = transforms.Compose([transforms.ToTensor(),
                                    transforms.Normalize((0.5,), (0.5,))])

    trainset = MNIST(root='./data', train=True, download=True, transform=transform)
    trainloader = DataLoader(trainset, batch_size=bs, shuffle=True)

    testset = MNIST(root='./data', train=False, download=True, transform=transform)
    testloader = DataLoader(testset, batch_size=bs, shuffle=False)

    # training                                                                             
    print('train')
    model = model.train()
    for iepoch in range(3):
        for iiter, (x, y) in enumerate(trainloader, 0):

            # toGPU (CPUの場合はtoCPU)                                                     
            x = x.to(device)
            y = torch.eye(10)[y].to(device)

            # 推定                                                                         
            y_ = model.forward(x) # y_.shape = (bs, 84)                                    

            # loss: cross-entropy                                                          
            eps = 1e-7
            loss = -torch.mean(y*torch.log(y_+eps))

            opt.zero_grad() # 勾配初期化
            loss.backward() # backward (勾配計算)
            opt.step() # パラメータの微小移動

            # 100回に1回進捗を表示(なくてもよい)
            if iiter%100==0:
                print('%03d epoch, %05d, loss=%.5f' %
                      (iepoch, iiter, loss.item()))

    # test                                                                                 
    print('test')
    total, tp = 0, 0
    model = model.eval()
    for (x, label) in testloader:

        # to GPU                                                                           
        x = x.to(device)

        # 推定                                                                             
        y_ = model.forward(x)
        label_ = y_.argmax(1).to('cpu')

        # 結果集計                                                                         
        total += label.shape[0]
        tp += (label_==label).sum().item()

    acc = tp/total
    print('test accuracy = %.3f' % acc)

結果

とりあえず5回ほど動かしてみた結果が以下。

test accuracy = 0.98240
test accuracy = 0.98450
test accuracy = 0.98640
test accuracy = 0.97970
test accuracy = 0.98540

同じ回数学習しても初期値の乱数の違いによって最終的な精度が異なるということが重要。例えば構造を少し変更してみて精度が0.005 (0.5%)上がっても、それが初期値乱数の誤差の範囲内なのか、本当に構造の変化によるものなかはきちんと評価しないといけない。初期値を止めて比較するというのもあまりお勧めできない。

20回くらい同じ条件で計算して精度の平均と標準偏差をきちんと求めて構造の違いを比較し、構造の違いによって精度平均が有意に上がったというのであれば、信頼できる結果になる。この辺はまたいつかまとめてみようと思う。

参考サイト

CNNのアーキテクチャはこちらのサイトを参考にした。 www.kaggle.com