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)