人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

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クラス分類タスクに応用してみよう。

f:id:aidiary:20180213225109j:plain:medium f:id:aidiary:20180213225130j:plain:medium

・・・なんでアリとハチなんだろうね(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)

f:id:aidiary:20180213231808p:plain f:id:aidiary:20180213231819p:plain

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)

f:id:aidiary:20180213232016p:plain f:id:aidiary:20180213232027p:plain

Resize

Resize the input PIL Image to the given size.

  • 古いバージョンのPyTorchだとScale()が使われているがこれは deprecatedResize() 使えと促される。
plt.figure()
plt.imshow(image)

t = transforms.Resize((256, 256))
trans_image = t(image)

plt.figure()
plt.imshow(trans_image)

f:id:aidiary:20180213231808p:plain f:id:aidiary:20180213232209p:plain

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)

f:id:aidiary:20180213232016p:plain f:id:aidiary:20180213232252p:plain

もうやだ、くじけそう(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])
    ]),
}
  • 訓練時と評価時ではデータ変換関数が異なることに注意
  • 訓練時は汎化性能が上がるように RandomResizedCropRandomHorizontalFlip などデータ拡張する変換を入れる
  • 評価時はこのようなランダム性は入れずに入力画像のサイズがネットワークに合うようにサイズを変形するだけ
  • 少し大きめ(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)

f:id:aidiary:20180217091130p:plain

訓練用関数の定義

次は訓練ループ。これまでとちょっと違う。

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)

f:id:aidiary:20180217105004p:plain (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する例をやってみます。

参考

*1:Deep LearningのMOOCでとても有名な fast.ai では第1回目の講義がTransfer Learningである

*2:大丈夫!犬と猫もあとでやります!

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)

f:id:aidiary:20180212105211p:plain

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

f:id:aidiary:20180212105510p:plain

中心部の 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

f:id:aidiary:20180212105211p:plain

img, label, prob = predict('./data/20170104210658.jpg')
print(label, prob)  # ['n04147183', 'schooner'] 0.942729
img

f:id:aidiary:20180212112100p:plain

img, label, prob = predict('./data/20170104210705.jpg')
print(label, prob)  # ['n02699494', 'altar'] 0.823404
img

f:id:aidiary:20180212112141p:plain

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っぽく書けるのでいい!
  • Conv2dBatchNorm2d は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')

f:id:aidiary:20180205160944p:plain

いい感じ。

参考のチュートリアルに従って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()

f:id:aidiary:20180205161516p:plain f:id:aidiary:20180205162157p:plain

今回はチュートリアルのシンプルなCNNなので63%くらい。あとでチューニングしてみよう!

参考