PyTorch (8) Transfer Learning (Ants and Bees)
今回は、公式にあるPyTorch TutorialのTransfer Learning Tutorialを追試してみた!
180205-transfer-learning-tutorial.ipynb - Google ドライブ
前回(2018/2/12)取り上げたVGGやResNetのような大規模な畳み込みニューラルネット(CNN)をスクラッチ(ランダム重み)から学習させられる人は少ない。大規模なデータとマシンパワーが必要になるためだ。
そんな"貧乏人"の強い味方が転移学習(Transfer Learning)。これはDeep Learningを始めたらすぐにでも身につけるべき超重要テクと言える*1
転移学習は、(ImageNetなどの)大規模データで学習済みのモデルを別のタスクに応用(転移)する技術全般を指す。
今回は、ImageNetで学習した1000クラスの分類モデルをアリとハチの2クラス分類タスクに応用してみよう。
・・・なんでアリとハチなんだろうね(T_T)
ここは普通、かわいい 犬とか猫(2017/1/8)が出てくるところだよね*2。
import torch import torch.nn as nn import torch.optim as optim from torch.optim import lr_scheduler from torch.autograd import Variable import torchvision from torchvision import datasets, models, transforms import os import time import copy import numpy as np import matplotlib.pyplot as plt
データのロード
!wget https://download.pytorch.org/tutorial/hymenoptera_data.zip -P data/
- 画像からアリとハチを分類するモデルを学習する
- PyTorchのチュートリアルのサイトからアリとハチの画像をダウンロードできる
- 訓練画像は244枚、バリデーション画像は153枚(ImageNetのサブセット)
- スクラッチからCNNを学習するにはこの画像数では少なすぎるが Transfer Learning なら十分可能!Deep Learningは一般的に大量のデータが必要と言われるが、Transfer Learningをうまく使いこなせば少量のデータでも十分学習できるのだ!
さっそく解凍してデータをじっくり眺めてみよう。アリとハチのかわいい画像がたくさんあるね (T_T)
ディレクトリから画像をロード
Kerasでは画像が入ったディレクトリからデータをロードする関数としてImageDataGenerator.flow_from_directory(directory)
が提供されていた。PyTorchも同じような機能としてImageFolder
が用意されている。
- 画像フォルダからデータをPIL形式で読み込むにはtorchvision.datasets.ImageFolderを使う
- ImageFolderにはtransform引数があってここにデータ拡張を行う変換関数群を指定すると簡単にデータ拡張ができる
- KerasのImageDataGeneratorと同じでディレクトリの下にクラス名のサブディレクトリ(
train/ants
,train/bees
)を作っておくと自動的にクラス(ants
bees
)を認識する。ラベルはアルファベット順に0, 1, 2, ...と割り当てられる(要確認)
まずはデータ拡張は無視して train
ディレクトリから画像を読み込んでみよう。この関数は全画像データをまとめてメモリにロードするわけではないので対象ディレクトリに大規模な画像ファイルがあっても問題ない。
data_dir = os.path.join('data', 'hymenoptera_data') image_dataset = datasets.ImageFolder(os.path.join(data_dir, 'train')) print(len(image_dataset)) # 244枚の訓練データ image, label = image_dataset[0] # 0番目の画像とラベル
データ拡張
Kerasにもさまざまなデータ拡張(2016/12/12)が用意されていたが、PyTorchもそれに劣らずたくさんある。全部は紹介しきれないので今回のプロジェクトで使うものだけ。左がオリジナルで右が変換した(拡張した)画像。
RandomResizedCrop
Crop the given PIL Image to random size and aspect ratio.
plt.figure()
plt.imshow(image)
t = transforms.RandomResizedCrop(224)
trans_image = t(image)
plt.figure()
plt.imshow(trans_image)
RandomHorizontalFlip
Horizontally flip the given PIL Image randomly with a probability of 0.5.
plt.figure() plt.imshow(image) t = transforms.RandomHorizontalFlip() trans_image = t(image) plt.figure() plt.imshow(trans_image)
Resize
Resize the input PIL Image to the given size.
- 古いバージョンのPyTorchだと
Scale()
が使われているがこれは deprecated。Resize()
使えと促される。
plt.figure() plt.imshow(image) t = transforms.Resize((256, 256)) trans_image = t(image) plt.figure() plt.imshow(trans_image)
CenterCrop
Crops the given PIL Image at the center.
plt.figure()
plt.imshow(image)
t = transforms.CenterCrop(224)
trans_image = t(image)
plt.figure()
plt.imshow(trans_image)
もうやだ、くじけそう(T_T)
データ変換関数の作成
上のを全部入りしたデータ変換関数を作る。
data_transforms = { 'train': transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]), 'val': transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]), }
- 訓練時と評価時ではデータ変換関数が異なることに注意
- 訓練時は汎化性能が上がるように
RandomResizedCrop
やRandomHorizontalFlip
などデータ拡張する変換を入れる - 評価時はこのようなランダム性は入れずに入力画像のサイズがネットワークに合うようにサイズを変形するだけ
- 少し大きめ(256x256)にリサイズしてから中心部分の 224x224 を切り出すのはよく使うのかな?いきなり 224x224 にリサイズよりいいのだろうか?ImageNetがそうしている?
Normalize()
はImageNetの訓練データの平均と分散を表し、入力画像を平均0、分散1に標準化する。ImageNetで学習済みの重みを使うときは必ず入れる変換(2018/2/12)- 辞書を使っているのでちょっと難しいが、
data_transforms['train']()
が訓練画像用の変換関数で、data_transforms['val']()
が評価画像用の変換関数になる
DataSetとDataLoader
data_dir = os.path.join('data', 'hymenoptera_data') image_datasets = {x: datasets.ImageFolder(os.path.join(data_dir, x), data_transforms[x]) for x in ['train', 'val']} dataloaders = {x: torch.utils.data.DataLoader(image_datasets[x], batch_size=4, shuffle=True, num_workers=4) for x in ['train', 'val']} dataset_sizes = {x: len(image_datasets[x]) for x in ['train', 'val']} class_names = image_datasets['train'].classes
- DataSetをディレクトリから画像を直接読み込む
ImageFolder
で作る ImageFolder
の第2引数にデータ変換用の関数を指定する- 辞書を使ったりリスト内包表記を使ってるのでちょっと見にくいが今までと同じ
訓練画像の可視化
def imshow(images, title=None): images = images.numpy().transpose((1, 2, 0)) # (h, w, c) mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) images = std * images + mean images = np.clip(images, 0, 1) plt.imshow(images) if title is not None: plt.title(title) images, classes = next(iter(dataloaders['train'])) print(images.size(), classes.size()) # torch.Size([4, 3, 224, 224]) torch.Size([4]) images = torchvision.utils.make_grid(images) imshow(images, title=[class_names[x] for x in classes])
torch.Size([4, 3, 224, 224]) torch.Size([4])
- データ変換に
ToTensor()
してしまったためDataLoaderで供給されるデータはテンソルになっていてそのままでは描画できない - そのためテンソルを
numpy()
でndarrayに戻してからmatplotlibで描画している - さらにデータ標準化もしているため元のピクセル値(0-1)に戻すため逆演算している(標準偏差をかけて平均足すと戻る)
- 前回(2018/2/12)のようにデータ描画用に
transforms.ToTensor()
とtransforms.Normalize()
だけを除いたdata_transforms
を作るのもありかも。これだとPIL.Image
のままなのでそのまま描画可 - バッチサイズが4なのでデータ拡張を適用済みの4枚の画像が返される(T_T)
訓練用関数の定義
次は訓練ループ。これまでとちょっと違う。
use_gpu = torch.cuda.is_available() def train_model(model, criterion, optimizer, scheduler, num_epochs=25): since = time.time() best_model_wts = copy.deepcopy(model.state_dict()) best_acc = 0.0 for epoch in range(num_epochs): print('Epoch {}/{}'.format(epoch, num_epochs - 1)) print('-' * 10) # 各エポックで訓練+バリデーションを実行 for phase in ['train', 'val']: if phase == 'train': scheduler.step() model.train(True) # training mode else: model.train(False) # evaluate mode running_loss = 0.0 running_corrects = 0 for data in dataloaders[phase]: inputs, labels = data if use_gpu: inputs = Variable(inputs.cuda()) labels = Variable(labels.cuda()) else: inputs, labels = Variable(inputs), Variable(labels) optimizer.zero_grad() # forward outputs = model(inputs) _, preds = torch.max(outputs.data, 1) loss = criterion(outputs, labels) if phase == 'train': loss.backward() optimizer.step() # statistics running_loss += loss.data[0] * inputs.size(0) running_corrects += torch.sum(preds == labels.data) # サンプル数で割って平均を求める epoch_loss = running_loss / dataset_sizes[phase] epoch_acc = running_corrects / dataset_sizes[phase] print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc)) # deep copy the model # 精度が改善したらモデルを保存する if phase == 'val' and epoch_acc > best_acc: best_acc = epoch_acc best_model_wts = copy.deepcopy(model.state_dict()) print() time_elapsed = time.time() - since print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed // 60, time_elapsed % 60)) print('Best val acc: {:.4f}'.format(best_acc)) # load best model weights model.load_state_dict(best_model_wts) return model
- 各エポックでは訓練(train)+バリデーションデータに対する評価(val)を行う
- 今までは
train()
valid()
という別の関数を書いていたが共通処理が多いためまとめている - ループで共通化するためにDataSetやDataLoaderを辞書化していた
- モデルを評価モードにするには
model.eval()
でなくmodel.train(False)
と書いてもよい - PyTorchのloss関数はデフォルトでミニバッチのサンプルあたりの平均lossを返す仕様になっている(
size_average=True
)。そのため、running_loss
はミニバッチの平均lossをミニバッチのサンプル数倍したものを加えていき、最後に全サンプル数で 割ってサンプルあたりの平均lossを求めている。これまでの実装 (2018/2/5)のようにミニバッチの平均lossを加えていって、最後に ミニバッチ数で 割っても同じだと思う。
size_average (bool, optional) – By default, the losses are averaged over observations for each minibatch. However, if the field size_average is set to False, the losses are instead summed for each minibatch. Ignored if reduce is False. http://pytorch.org/docs/master/nn.html?highlight=loss#crossentropyloss
- この関数は最終的にバリデーションデータで最高精度が出たエポックのモデルを返す
- 学習率のスケジューラやベストモデルの保存方法も参考になる!(あとでまとめよう)
この訓練ループの書き方は確かにエレガントなんだけど初見でわかりにくいなあ。自分は前に書いた(2018/2/5)ように train()
valid()
に重複があっても別々の関数にするほうが好みかも。
学習済みモデルをFine-tuning (Finetuning the convnet)
学習済みの大規模なネットワーク(今回はResNet)の出力層部分のみ2クラス分類になるように置き換えて、重みを固定せずに新規データで全層を再チューニングする。
まずは学習済みのResNet18をロード
model_ft = models.resnet18(pretrained=True) print(model_ft)
ResNet( (conv1): Conv2d (3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False) (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (maxpool): MaxPool2d(kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), dilation=(1, 1)) (layer1): Sequential( (0): BasicBlock( (conv1): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) ) (1): BasicBlock( (conv1): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) ) ) (layer2): Sequential( (0): BasicBlock( (conv1): Conv2d (64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False) (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) (downsample): Sequential( (0): Conv2d (64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False) (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) ) ) (1): BasicBlock( (conv1): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) ) ) (layer3): Sequential( (0): BasicBlock( (conv1): Conv2d (128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False) (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True) (downsample): Sequential( (0): Conv2d (128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False) (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True) ) ) (1): BasicBlock( (conv1): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True) ) ) (layer4): Sequential( (0): BasicBlock( (conv1): Conv2d (256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False) (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True) (downsample): Sequential( (0): Conv2d (256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False) (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True) ) ) (1): BasicBlock( (conv1): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True) (relu): ReLU(inplace) (conv2): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True) ) ) (avgpool): AvgPool2d(kernel_size=7, stride=1, padding=0, ceil_mode=False, count_include_pad=True) (fc): Linear(in_features=512, out_features=1000) )
ResNetの構造はあとで詳しく見てみる予定だけどとりあえず最後の (fc)
に注目。出力がImageNetの1000クラス分類なので Linear(512, 1000)
になっている。ここを2クラスを出力する置き換えればアリとハチの分類に使える。
num_features = model_ft.fc.in_features print(num_features) # 512 # fc層を置き換える model_ft.fc = nn.Linear(num_features, 2) print(model_ft)
置き換えるのは新しいレイヤを作成して代入するだけなのですごく簡単にできる。
ResNet( (... 省略 ...) (avgpool): AvgPool2d(kernel_size=7, stride=1, padding=0, ceil_mode=False, count_include_pad=True) (fc): Linear(in_features=512, out_features=2) )
2クラスになってる!あとはこれまでと同様に普通に学習するだけ。
- TODO: 出力層のユニット数を1にして活性化関数をsigmoid、Loss関数をbinary cross entropyにしてもよいかも
if use_gpu: model_ft = model_ft.cuda() criterion = nn.CrossEntropyLoss() optimizer_ft = optim.SGD(model_ft.parameters(), lr=0.001, momentum=0.9) # 7エポックごとに学習率を0.1倍する exp_lr_scheduler = lr_scheduler.StepLR(optimizer_ft, step_size=7, gamma=0.1) model_ft = train_model(model_ft, criterion, optimizer_ft, exp_lr_scheduler, num_epochs=25) torch.save(model_ft.state_dict(), 'model_ft.pkl')
(省略) Epoch 20/24 ---------- train Loss: 0.2950 Acc: 0.8689 val Loss: 0.2302 Acc: 0.9020 Epoch 21/24 ---------- train Loss: 0.2466 Acc: 0.8893 val Loss: 0.2107 Acc: 0.9281 Epoch 22/24 ---------- train Loss: 0.3057 Acc: 0.8648 val Loss: 0.2204 Acc: 0.9216 Epoch 23/24 ---------- train Loss: 0.2220 Acc: 0.9180 val Loss: 0.2031 Acc: 0.9281 Epoch 24/24 ---------- train Loss: 0.3338 Acc: 0.8648 val Loss: 0.2066 Acc: 0.9216 Training complete in 1m 60s Best val acc: 0.9281
アリとハチの分類精度は 92.81% でした
学習済みの重みを固定 (ConvNet as fixed feature extractor)
先ほどは重みを固定せずにResNet18の全レイヤの重みを更新対象にしていた。しかし、Fine-tuningする場合は学習済みの重みを壊さないように固定した方がよいケースもある。
ここでは、新しく追加した Linear(512, 2)
のみをパラメータ更新の対象として残りのレイヤの重みはすべて固定してみた。
# 訓練済みResNet18をロード model_conv = torchvision.models.resnet18(pretrained=True) # すべてのパラメータを固定 for param in model_conv.parameters(): param.requires_grad = False # 最後のfc層を置き換える # これはデフォルトの requires_grad=True のままなのでパラメータ更新対象 num_features = model_conv.fc.in_features model_conv.fc = nn.Linear(num_features, 2) if use_gpu: model_conv = model_conv.cuda() criterion = nn.CrossEntropyLoss() # Optimizerの第1引数には更新対象のfc層のパラメータのみ指定 optimizer_conv = optim.SGD(model_conv.fc.parameters(), lr=0.001, momentum=0.9) exp_lr_scheduler = lr_scheduler.StepLR(optimizer_conv, step_size=7, gamma=0.1)
require_grad = False
とすると重みを固定できる- optimizerには更新対象のパラメータのみ渡す必要がある ことに注意!
model_conv.parameters()
と固定したパラメータ含めて全部渡そうとするとエラーになる - backwardの勾配計算はネットワークの大部分で計算しなくて済むため前に比べて学習は早い(CPUでも動くレベル)。しかし、lossを計算するためforwardは計算しないといけない
model_conv = train_model(model_conv, criterion, optimizer_conv,
exp_lr_scheduler, num_epochs=25)
Epoch 20/24 ---------- train Loss: 0.2673 Acc: 0.8689 val Loss: 0.2769 Acc: 0.8954 Epoch 21/24 ---------- train Loss: 0.3839 Acc: 0.8525 val Loss: 0.2166 Acc: 0.9281 Epoch 22/24 ---------- train Loss: 0.2491 Acc: 0.8770 val Loss: 0.2111 Acc: 0.9281 Epoch 23/24 ---------- train Loss: 0.3565 Acc: 0.8484 val Loss: 0.2198 Acc: 0.9216 Epoch 24/24 ---------- train Loss: 0.3040 Acc: 0.8648 val Loss: 0.2149 Acc: 0.9216 Training complete in 16m 24s Best val acc: 0.9412
最終的に 94.12% でした。先ほどは92%だったので少しよくなった!
今までいろんなデータセットでFine-tuningを試してきたけど重みを固定したほうがよいか、固定しないほうがよいかはデータによってまちまちなのでいろんなパターンを試した方がよさそう。上記以外にも例えば、
- 上位レイヤだけ固定する
- BatchNormalization層だけ固定しない
とかいろいろ試してみることをオススメします!
分類結果の可視化
最後に分類結果を可視化して終わりにしよう。
# GPUで学習したモデルのロード model_ft.load_state_dict(torch.load('model_ft.pkl', map_location=lambda storage, loc: storage)) def visualize_model(model, num_images=6): images_so_far = 0 fig = plt.figure() for i, data in enumerate(dataloaders['val']): inputs, labels = data if use_gpu: inputs, labels = Variable(inputs.cuda()), Variable(labels.cuda()) else: inputs, labels = Variable(inputs), Variable(labels) outputs = model(inputs) _, preds = torch.max(outputs.data, 1) for j in range(inputs.size()[0]): images_so_far += 1 ax = plt.subplot(num_images // 2, 2, images_so_far) ax.axis('off') ax.set_title('predicted: {}'.format(class_names[preds[j]])) imshow(inputs.cpu().data[j]) if images_so_far == num_images: return visualize_model(model_ft)
(T_T)
学習はGPUマシンで結果の可視化や分析は手元のCPUマシンでということはよくあると思う。GPU上で学習したモデルをCPUに持ってくるときは要注意!オプションを指定せずに load_state_dict()
すると AssertionError: Torch not compiled with CUDA enabled
というエラーが出る。その場合は、map_location
を上のように指定すればOK。
参考: https://discuss.pytorch.org/t/loading-weights-for-cpu-model-while-trained-on-gpu/1032
データ拡張からFine-tuningまでいろいろ詰め込んだら長くなってしまった・・・
次回からさまざまな公開画像データを使ってFine-tuningする例をやってみます。
参考
- Transfer Learning Tutorial
- VGG16のFine-tuningによる犬猫認識 (1)(2017/1/8)
- VGG16のFine-tuningによる犬猫認識 (2)(2017/1/10)
- VGG16のFine-tuningによる17種類の花の分類(2017/1/31)
PyTorch (7) VGG16
今回は、学習済みのVGG16を使ってImageNetの1000クラスの画像分類を試してみた。以前、Kerasでやった(2017/1/4)ことのPyTorch版。
180208-vgg16.ipynb - Google ドライブ
import torch import torch.nn as nn import torch.optim as optim from torch.autograd import Variable import torchvision from torchvision import datasets, models, transforms import json import numpy as np from PIL import Image
モデルのロード
VGG16は vgg16
クラスとして実装されている。pretrained=True
にするとImageNetで学習済みの重みがロードされる。
vgg16 = models.vgg16(pretrained=True)
print(vgg16)
するとモデル構造が表示される。(features)
と(classifier)
の2つのSequentialモデルから成り立っていることがわかる。VGG16は Conv2d => ReLU => MaxPool2d
の繰り返しからなる単純な構造。
VGG( (features): Sequential( (0): Conv2d (3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (1): ReLU(inplace) (2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (3): ReLU(inplace) (4): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1)) (5): Conv2d (64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (6): ReLU(inplace) (7): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (8): ReLU(inplace) (9): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1)) (10): Conv2d (128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (11): ReLU(inplace) (12): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (13): ReLU(inplace) (14): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (15): ReLU(inplace) (16): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1)) (17): Conv2d (256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (18): ReLU(inplace) (19): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (20): ReLU(inplace) (21): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (22): ReLU(inplace) (23): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1)) (24): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (25): ReLU(inplace) (26): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (27): ReLU(inplace) (28): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (29): ReLU(inplace) (30): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1)) ) (classifier): Sequential( (0): Linear(in_features=25088, out_features=4096) (1): ReLU(inplace) (2): Dropout(p=0.5) (3): Linear(in_features=4096, out_features=4096) (4): ReLU(inplace) (5): Dropout(p=0.5) (6): Linear(in_features=4096, out_features=1000) ) )
VGG16以外にもVGG11、VGG13、VGG19もあり、それぞれにBatch Normalizationを加えたモデルも公開されている。これは便利。
推論するときは eval()
で評価モードに切り替えること!
Some models use modules which have different training and evaluation behavior, such as batch normalization. To switch between these modes, use model.train() or model.eval() as appropriate. See train() or eval() for details. http://pytorch.org/docs/master/torchvision/models.html
vgg16.eval()
これを忘れると推論するたびに出力結果が変わってしまうのでおかしいと気づく。
前処理
ImageNetで学習した重みを使うときはImageNetの学習時と同じデータ標準化を入力画像に施す必要がある。
All pre-trained models expect input images normalized in the same way, i.e. mini-batches of 3-channel RGB images of shape (3 x H x W), where H and W are expected to be at least 224. The images have to be loaded in to a range of [0, 1] and then normalized using mean = [0.485, 0.456, 0.406] and std = [0.229, 0.224, 0.225]. You can use the following transform to normalize: http://pytorch.org/docs/master/torchvision/models.html
normalize = transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) preprocess = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), normalize ])
入力画像に対して以下の変換を施している。
- 256 x 256にリサイズ
- 画像の中心部分の 224 x 224 のみ取り出す
- テンソルに変換
- ImageNetの訓練データのmeanを引いてstdで割る(標準化)
試しに画像を入れてみよう。PyTorchでは基本的に画像のロードはPILを使う。先ほど作成した preprocess
に通してみよう。
img = Image.open('./data/20170104210653.jpg') img_tensor = preprocess(img) print(img_tensor.shape)
torch.Size([3, 224, 224])
画像が3Dテンソルに変換される。
- Keras (TensorFlow)と違ってチャンネルが前にくる
- バッチサイズがついた4Dテンソルではない
ことに注意。画像として表示したい場合は ToTensor()
する前までの変換関数を用意すればよい。これならPILのままなので普通にJupyter Notebook上で画像描画できる。
preprocess2 = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224) ]) trans_img = preprocess2(img) print(type(trans_img)) # <class 'PIL.Image.Image'> trans_img
中心部の 224 x 224 が切り取られていることがわかる。
画像分類
画像をモデルに入力するときは3Dテンソルではなく、バッチサイズの次元を先頭に追加した4Dテンソルにする必要がある。次元を増やすのは unsqueeze_()
でできる。アンダーバーがついた関数はIn-placeで属性を置き換える。
img_tensor.unsqueeze_(0) print(img_tensor.size()) # torch.Size([1, 3, 224, 224])
モデルへ入れるときは4DテンソルをVariableに変換する必要がある。
out = vgg16(Variable(img_tensor)) print(out.size()) # torch.Size([1, 1000])
outはsoftmaxを取る前の値なので確率になっていない(足して1.0にならない)。だが、分類するときは確率にする必要がなく、出力が最大値のクラスに分類すればよい。
np.argmax(out.data.numpy()) # 332
出力が大きい順にtop Kを求めたいときは topk()
という関数がある。
out.topk(5)
下のように出力とそのインデックスが返ってくる。
(Variable containing: 28.5678 18.9699 18.1706 16.8523 16.8499 [torch.FloatTensor of size 1x5], Variable containing: 332 338 333 283 331 [torch.LongTensor of size 1x5])
ImageNetの1000クラスの332番目のインデックスのクラスに分類されたけどこれはなんだろう?
ImageNetの1000クラスラベル
PyTorchにはImageNetの1000クラスのラベルを取得する機能はついていないようだ。ImageNetの1000クラスのラベル情報はここからJSON形式でダウンロードできるので落とす。
!wget https://s3.amazonaws.com/deep-learning-models/image-models/imagenet_class_index.json
class_index = json.load(open('imagenet_class_index.json', 'r')) print(class_index)
{'0': ['n01440764', 'tench'], '1': ['n01443537', 'goldfish'], '2': ['n01484850', 'great_white_shark'], '3': ['n01491361', 'tiger_shark'], '4': ['n01494475', 'hammerhead'], '5': ['n01496331', 'electric_ray'], '6': ['n01498041', 'stingray'], '7': ['n01514668', 'cock'], '8': ['n01514859', 'hen'], '9': ['n01518878', 'ostrich'], '10': ['n01530575', 'brambling'],
labels = {int(key):value for (key, value) in class_index.items()} print(labels[0]) # ['n01440764', 'tench'] print(labels[1]) # ['n01443537', 'goldfish']
332番目のクラスは・・・
print(labels[np.argmax(out.data.numpy())])
['n02328150', 'Angora']
アンゴラちゃんでした!
テスト
関数化していくつかの画像で評価してみよう。
def predict(image_file): img = Image.open(image_file) img_tensor = preprocess(img) img_tensor.unsqueeze_(0) out = vgg16(Variable(img_tensor)) # 出力を確率にする(分類するだけなら不要) out = nn.functional.softmax(out, dim=1) out = out.data.numpy() maxid = np.argmax(out) maxprob = np.max(out) label = labels[maxid] return img, label, maxprob
img, label, prob = predict('./data/20170104210653.jpg') print(label, prob) # ['n02328150', 'Angora'] 0.999879 img
img, label, prob = predict('./data/20170104210658.jpg') print(label, prob) # ['n04147183', 'schooner'] 0.942729 img
img, label, prob = predict('./data/20170104210705.jpg') print(label, prob) # ['n02699494', 'altar'] 0.823404 img
Kerasのときと微妙に結果が違うな。
参考
PyTorch (6) Convolutional Neural Network
今回は畳み込みニューラルネットワーク。MNISTとCIFAR-10で実験してみた。
MNIST
import numpy as np import torch import torch.nn as nn import torchvision.datasets as dsets import torchvision.transforms as transforms # Hyperparameters num_epochs = 10 batch_size = 100 learning_rate = 0.001 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(device)
今回からGPU対応した。PyTorchはKerasと違ってGPUモードにするために明示的にコーディングが必要。ここがちょっと面倒><
# MNIST Dataset (Images and Labels) train_dataset = dsets.MNIST(root='./data', train=True, transform=transforms.ToTensor(), download=True) test_dataset = dsets.MNIST(root='./data', train=False, transform=transforms.ToTensor()) # Dataset Loader (Input Pipline) train_loader = torch.utils.data.DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True) test_loader = torch.utils.data.DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False)
MNISTを読み込むDataSetとDataLoaderを作成。前回(2018/2/4)と同じ。
class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.layer1 = nn.Sequential( nn.Conv2d(1, 16, kernel_size=5, padding=2), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2)) self.layer2 = nn.Sequential( nn.Conv2d(16, 32, kernel_size=5, padding=2), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2)) self.fc = nn.Linear(7 * 7 * 32, 10) def forward(self, x): out = self.layer1(x) out = self.layer2(out) out = out.view(out.size(0), -1) out = self.fc(out) return out
- CNNはブロック単位で処理した方がよいのでブロック(Conv+BN+ReLU+Pooling)ごとにまとめて
Sequential
を使うとわかりやすくなる。Kerasっぽく書けるのでいい! Conv2d
やBatchNorm2d
はKerasと違って入力と出力のユニットサイズを省略できない。- サイズを自分で計算するのが面倒ならば、モデルの途中結果サイズを
print(out.size())
で出力してみるとよい。
# テスト model = CNN().to(device) images, labels = iter(train_loader).next() print(images.size()) images = images.to(device) outputs = model(images)
torch.Size([100, 1, 28, 28])
モデルオブジェクトの作成。
model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
GPUモードで動かすには
- モデルを
to(device)
でGPUに転送する - テンソルデータも
to(device)
でGPUに転送する
の2つだけ実装すればよい。Kerasより面倒だけど意外に簡単。
print(model)
するといい感じで構造が表示される。
CNN( (layer1): Sequential( (0): Conv2d(1, 16, kernel_size=(5, 5), stride=(1, 1), padding=(2, 2)) (1): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (2): ReLU() (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) ) (layer2): Sequential( (0): Conv2d(16, 32, kernel_size=(5, 5), stride=(1, 1), padding=(2, 2)) (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (2): ReLU() (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) ) (fc): Linear(in_features=1568, out_features=10, bias=True) )
次は訓練ループ。これまでとほとんど同じ。
def train(train_loader): model.train() running_loss = 0 for batch_idx, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() loss.backward() optimizer.step() train_loss = running_loss / len(train_loader) return train_loss def valid(test_loader): model.eval() running_loss = 0 correct = 0 total = 0 with torch.no_grad(): for batch_idx, (images, labels) in enumerate(test_loader): images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() predicted = outputs.max(1, keepdim=True)[1] correct += predicted.eq(labels.view_as(predicted)).sum().item() total += labels.size(0) val_loss = running_loss / len(test_loader) val_acc = correct / total return val_loss, val_acc loss_list = [] val_loss_list = [] val_acc_list = [] for epoch in range(num_epochs): loss = train(train_loader) val_loss, val_acc = valid(test_loader) print('epoch %d, loss: %.4f val_loss: %.4f val_acc: %.4f' % (epoch, loss, val_loss, val_acc)) # logging loss_list.append(loss) val_loss_list.append(val_loss) val_acc_list.append(val_acc) # save the trained model np.save('loss_list.npy', np.array(loss_list)) np.save('val_loss_list.npy', np.array(val_loss_list)) np.save('val_acc_list.npy', np.array(val_acc_list)) torch.save(model.state_dict(), 'cnn.pkl')
Batch Normalizationなど学習時と推論時で挙動が変わるレイヤを使う場合はモデルのモードに要注意!
model.train()
で訓練モードmodel.eval()
で評価モード
に切り替える必要がある。切り替えなくてもエラーにはならないが性能が出なかったりする。Kerasにはなかったので忘れやすい!
また先に書いたようにGPUモードで動かすときはテンソルを下のように to(device)
でGPUに送る必要がある!
images = images.to(device) labels = labels.to(device)
GPUで動かすと下のようになった。
epoch 0, loss: 0.1682 val_loss: 0.0545 val_acc: 0.9819 epoch 1, loss: 0.0498 val_loss: 0.0398 val_acc: 0.9865 epoch 2, loss: 0.0386 val_loss: 0.0330 val_acc: 0.9886 epoch 3, loss: 0.0292 val_loss: 0.0351 val_acc: 0.9887 epoch 4, loss: 0.0248 val_loss: 0.0296 val_acc: 0.9902 epoch 5, loss: 0.0203 val_loss: 0.0320 val_acc: 0.9894 epoch 6, loss: 0.0179 val_loss: 0.0342 val_acc: 0.9886 epoch 7, loss: 0.0144 val_loss: 0.0309 val_acc: 0.9894 epoch 8, loss: 0.0121 val_loss: 0.0285 val_acc: 0.9912 epoch 9, loss: 0.0109 val_loss: 0.0361 val_acc: 0.9898
前回、多層パーセプトロンでの精度が85%くらいだったので99.3%出るCNNはすごく性能がよいことがわかる。
CIFAR-10
次は同じことをCIFAR-10でやってみよう。
import numpy as np import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim import torchvision import torchvision.transforms as transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(device)
num_epochs = 30 batch_size = 128 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) # [0, 1] => [-1, 1] ]) train_set = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=4) test_set = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform) test_loader = torch.utils.data.DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=4) classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
- CIFAR-10のデータは画像のピクセルが [0, 1] ではなく、[-1, 1] になるように標準化している。
num_workers
を指定するとファイルの読み込みが並列化される(CPUのコアが複数ある場合はすごく速くなる!)
いくつかサンプルを描画してみよう。
import matplotlib.pyplot as plt import numpy as np %matplotlib inline def imshow(img): # unnormalize [-1, 1] => [0, 1] img = img / 2 + 0.5 npimg = img.numpy() # [c, h, w] => [h, w, c] plt.imshow(np.transpose(npimg, (1, 2, 0))) images, labels = iter(train_loader).next() images, labels = images[:16], labels[:16] imshow(torchvision.utils.make_grid(images, nrow=4, padding=1)) plt.axis('off')
いい感じ。
参考のチュートリアルに従ってCNNの簡単なモデルを定義してみた。上のMNISTよりシンプルという・・・
class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Conv2d(3, 6, kernel_size=5) self.pool = nn.MaxPool2d(2, 2) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(-1, 16 * 5 * 5) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = self.fc3(x) return x
ReLUは nn.ReLU()
で層として定義する場合もあるが、パラメータがないので F.relu()
のように関数として使うこともできる。層で書いておくとモデルを print
したときに表示される(さっきの例)。
model = CNN().to(device)
print(model)
CNN( (conv1): Conv2d(3, 6, kernel_size=(5, 5), stride=(1, 1)) (pool): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1)) (fc1): Linear(in_features=400, out_features=120, bias=True) (fc2): Linear(in_features=120, out_features=84, bias=True) (fc3): Linear(in_features=84, out_features=10, bias=True) )
この表示方法はシンプルだがとてもわかりやすい。
あとはこれまでと同じ。
criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) def train(train_loader): model.train() running_loss = 0 for i, (images, labels) in enumerate(train_loader): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() loss.backward() optimizer.step() train_loss = running_loss / len(train_loader) return train_loss def valid(test_loader): model.eval() running_loss = 0 correct = 0 total = 0 with torch.no_grad(): for i, (images, labels) in enumerate(test_loader): images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() predicted = outputs.max(1, keepdim=True)[1] correct += predicted.eq(labels.view_as(predicted)).sum().item() total += labels.size(0) val_loss = running_loss / len(test_loader) val_acc = correct / total return val_loss, val_acc loss_list = [] val_loss_list = [] val_acc_list = [] for epoch in range(num_epochs): loss = train(train_loader) val_loss, val_acc = valid(test_loader) print('epoch %d, loss: %.4f val_loss: %.4f val_acc: %.4f' % (epoch, loss, val_loss, val_acc)) # logging loss_list.append(loss) val_loss_list.append(val_loss) val_acc_list.append(val_acc) print('Finished training') # save the trained model np.save('loss_list.npy', np.array(loss_list)) np.save('val_loss_list.npy', np.array(val_loss_list)) np.save('val_acc_list.npy', np.array(val_acc_list)) torch.save(model.state_dict(), 'cnn.pkl')
結果をプロットしてみよう。
import numpy as np import matplotlib.pyplot as plt %matplotlib inline # plot learning curve plt.figure() plt.plot(range(num_epochs), loss_list, 'r-', label='train_loss') plt.plot(range(num_epochs), val_loss_list, 'b-', label='val_loss') plt.legend() plt.xlabel('epoch') plt.ylabel('loss') plt.grid() plt.figure() plt.plot(range(num_epochs), val_acc_list, 'g-', label='val_acc') plt.legend() plt.xlabel('epoch') plt.ylabel('acc') plt.grid()
今回はチュートリアルのシンプルなCNNなので63%くらい。あとでチューニングしてみよう!