Pytorchで自分で定義したCNNを使って、手書き数字MNISTの分類をやってみる。特にpytorchにおけるニューラルネットの書き方と、ネットワークの学習についてソースコードを交えて説明する。
ネットワークの定義
まずはネットワークの定義のやり方。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')
とすればGPUのtensorが使える。CPUのtensorをto('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
を含む場合はtrain
とeval
でネットワークの挙動が変わる。これを制御するの関数が、model.train()
とmodel.eval()
である。
特に論文などでtrain
とeval
を間違えると結果が変わって大変なので、明示的に学習の前には
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