人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 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:大丈夫!犬と猫もあとでやります!