Python
DeepLearning
Chainer

Chainerで転移学習とファインチューニング(VGG16、ResNet、GoogLeNet)

画像系の深層学習では、学習済みモデルの重みを利用する「転移学習」や「ファインチューニング」と呼ばれる手法がよく利用されます。

  • 転移学習: 学習済みのモデルから特徴量を抽出すること
  • ファインチューニング: 学習済みモデルの重みを使って再学習させること

どちらも基本的には、ILSVRCなどの画像認識コンペで優秀な成績を収めたモデルのネットワークアーキテクチャを深層学習のライブラリで構築し、公開されている学習済みの重みファイルを読み込ませて利用するという流れで実装します。

Chainerでは、以下の画像認識モデルが、すでに内部で実装されています。

  • VGG16
  • ResNet50, ResNet101, ResNet152
  • GoogLeNet

また、これらのモデルに学習済みの重みファイルを読み込ませるための便利な関数が一通り揃っていますので、それらの使い方についてまとめます。

Chainerのバージョン: 3.0.0

転移学習で画像特徴量を抽出する

Chainerから呼び出せる学習済みモデルはいずれも、ImageNetの1000クラス分類のタスクを学習したモデルになります。

今回のテスト用に入力する画像として、「パグ」の画像データを1枚用意しました。

img = Image.open('./pug.jpg')
plt.imshow(img)
plt.show()

1000クラスのラベルに「パグ」が含まれており、クラス番号は 254 になります。

Chainerから呼び出した学習済みモデルが、ちゃんとこの画像を入力して 254 を予測するか確認してみます。

VGG16

VGG16の学習済みモデルを呼び出すには、 chainer.links.VGGLayers 関数を使います。

vgg16 = chainer.links.VGG16Layers()

Chainerの実装では chainer.links.VGGLayers 関数の引数に pretrained_model が指定でき、何も指定しない場合にはデフォルトで学習済みモデルの重みを自動でダウンロードする仕組みになっています。

上記で、 重みを反映させたモデルインスタンス vgg16 を宣言したことになります。

実際に画像を入力して推論させる時は、モデルインスタンスの __call__ 関数に入力データをセットして実行することになります。

VGG16に限らず、ネットワークによって、入力する画像をそのネットワーク独自の前処理を行う場合がほとんどです。

例えば、VGG16の場合は入力画像に対し、

  • (batch_size, chennels, height, width) = (batch_size, 3, 224, 224) に合わせる
  • カラーのチャンネルの順番は (BGR) にする
  • 各画素値から平均値 (103.939, 116.779, 123.68) を引く

といった処理を施した後、ネットワークで推論させます。

Chainerでは、Pillow で読み込んだ画像データに対して、上記の処理を自動でやってくれる chainer.links.model.vision.vgg.prepare 関数があります。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.vgg.prepare(img)
x = x[np.newaxis] # batch size
result = vgg16(x)
F.argmax(F.softmax(result['prob'], axis=1), axis=1)
"""
variable([254])
"""

推論した結果には、クラス分類の最終レイヤーである全結合層のベクトル値が出力されますので、これを元に予測クラスを確認できます。

問題なく、 254 の「パグ」を推論してくれました。

さて、転移学習では、この学習済みモデルによる画像の特徴量を抽出することを指します。

一般的には、最終レイヤーの全結合層の一つ手前のレイヤーの出力ベクトル値を特徴量として得ることが多いですが、用途やレイヤーごとの結果を見て選択したりと様々です。

先程のモデルインスタンスの __call__ の実行には引数 layers が指定でき、どのレイヤーのベクトル値を得るかを指定することができます。

指定にはレイヤーの名前を文字列の配列で指定できます。

モデルが出力できるレイヤーの名前は以下で確認ができます。

vgg16.available_layers
"""
['conv1_1',
 'conv1_2',
 'pool1',
 'conv2_1',
 'conv2_2',
 'pool2',
 'conv3_1',
 'conv3_2',
 'conv3_3',
 'pool3',
 'conv4_1',
 'conv4_2',
 'conv4_3',
 'pool4',
 'conv5_1',
 'conv5_2',
 'conv5_3',
 'pool5',
 'fc6',
 'fc7',
 'fc8',
 'prob']
"""

VGG16の場合は最終レイヤーの全結合層が fc8 なので、手前の fc7fc6 などを指定して、以下のように出力ベクトル値を得ることができます。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.vgg.prepare(img)
x = x[np.newaxis]
result = vgg16(x, layers=['fc7'])
result['fc7'].data.squeeze().shape
"""
(4096,)
"""

また、Chainerでは モデルインスタンスの extract 関数を使うと、自動でクラス分類の全結合層の一つ手前のベクトル値を得ることができます。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.vgg.prepare(img)
x = x[np.newaxis]
result = vgg16.extract(x)
result['fc7'].data.squeeze().shape
"""
(4096,)
"""

extract 関数では、内部で chainer.links.model.vision.vgg.prepare による前処理も実装されているため、画像データをそのまま渡して結果を得ることも可能です。

img = Image.open('./pug.jpg')
img = [img] # batch size
result = vgg16.extract(img)
result['fc7'].data.squeeze().shape
"""
(4096,)
"""

以上が、Chainerを使って、学習済みのVGG16による特徴量を得る方法となります。

ResNet152

続いて、ResNet(ここでは例としてResNet152)になりますが、他のモデルもVGG16の時と同様のインタフェースで、学習済みモデルの読み込み、実行ができるようになっています。

ResNet152の場合は、chainer.links.ResNet152Layers モデルを呼び出します。

resnet152 = chainer.links.ResNet152Layers()

ResNet50の場合は chainer.links.ResNet50Layers、ResNet101の場合は chainer.links.ResNet101Layers でそれぞれモデルを宣言できます。

ResNetに対する入力画像の前処理も同様に、chainer.links.model.vision.resnet.prepare 関数で実行できます。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.resnet.prepare(img)
x = x[np.newaxis]
result = resnet152(x)
F.argmax(F.softmax(result['prob'], axis=1), axis=1)
"""
variable([254])
"""

モデルの推論で指定できるレイヤーは以下になります。

resnet152.available_layers
"""
['conv1', 'pool1', 'res2', 'res3', 'res4', 'res5', 'pool5', 'fc6', 'prob']
"""

よって、 res5pool5 などのベクトル値を以下のように取得できます。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.resnet.prepare(img)
x = x[np.newaxis]
result = resnet152(x, layers=['pool5'])
result['pool5'].data.squeeze().shape
"""
(2048,)
"""

ResNetも同様に extract 関数で、クラス分類の全結合層の一つ手前のベクトル値が得られます。

mg = Image.open('./pug.jpg')
x = chainer.links.model.vision.resnet.prepare(img)
x = x[np.newaxis]
result = resnet152.extract(x)
result['pool5'].data.squeeze().shape
"""
(2048,)
"""
img = Image.open('./pug.jpg')
img = [img]
result = resnet152.extract(img)
result['pool5'].data.squeeze().shape
"""
(2048,)
"""

GoogLeNet

GoogLeNetの場合も同様です。

モデルは chainer.links.GoogLeNet 関数で呼び出せます。

googlenet = chainer.links.GoogLeNet()

chainer.links.model.vision.googlenet.prepare 関数で前処理を施して、推論が可能です。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.googlenet.prepare(img)
x = x[np.newaxis]
result = googlenet(x)
F.argmax(F.softmax(result['prob'], axis=1), axis=1)
"""
variable([254])
"""

指定できるレイヤーは以下になります。

googlenet.available_layers
"""
['conv1',
 'pool1',
 'conv2_reduce',
 'conv2',
 'pool2',
 'inception_3a',
 'inception_3b',
 'pool3',
 'inception_4a',
 'inception_4b',
 'inception_4c',
 'inception_4d',
 'inception_4e',
 'pool4',
 'inception_5a',
 'inception_5b',
 'pool5',
 'loss3_fc',
 'prob',
 'loss1_fc2',
 'loss2_fc2']
"""

GoogLeNetの場合はネットワークがややこしく、評価時のロスを計算するレイヤーが複数あるのに対し、予測時に計算するレイヤーは loss3_fc のみになります。

どのレイヤーを指定するかは、やはり用途や結果を見てといった話になりますが、ラベルの予測レイヤーの手前のレイヤーだと、以下などになります。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.googlenet.prepare(img)
x = x[np.newaxis]
result = googlenet(x, layers=['pool5'])
result['pool5'].data.squeeze().shape
"""
(1024,)
"""

同様に extract 関数で、クラス分類の手前のベクトル値が得られます。

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.googlenet.prepare(img)
x = x[np.newaxis]
result = googlenet.extract(x)
result['pool5'].data.squeeze().shape
"""
(1024,)
"""
img = Image.open('./pug.jpg')
img = [img]
result = googlenet.extract(img)
result['pool5'].data.squeeze().shape
"""
(1024,)
"""

学習済みの重みを使ってファインチューニングする

学習済みモデルは、畳み込みなどのレイヤーで画像の特徴をうまく捉えられています。

これを利用して、最後の方のクラス分類をするレイヤーを任意のレイヤーに切り替えて再学習させると、一からモデルを学習させるよりも高い精度で学習できる場合があります。

そのようにして、学習済みモデルのレイヤーに任意のレイヤーを付け加えて再学習させる方法をファインチューニングといいます。

これまでに記した通り、Chainerでは学習済みモデルのネットワークを便利に使える関数が揃っていますので、ファインチューニングも比較的楽に実装できます。

VGG16

学習済みのVGG16をベースに、ファインチューニングさせるモデルを構築する例を紹介します。

転移学習の時に記したように、 layers 引数でモデルから出力させるレイヤーを選択できることを利用し、学習済みのレイヤーから得られたベクトルから、タスクに対応する任意のレイヤーを追加してモデルを実装することができます。

問題にもよりますが、VGG16の場合は、pool5層、fc6層、fc7層などのいずれかの出力を取って、新しくfc層を1〜3層追加することが多いです。

例えば、画像を入力して fc7層までの値をとり、最後に out_size 数のクラスに分類するモデルを実装した場合は下記のようになります。

class VGG16Model(chainer.Chain):

    def __init__(self, out_size):
        super(VGG16Model, self).__init__(
            base = L.VGG16Layers(),
            fc = L.Linear(None, out_size)
        )

    def __call__(self, x):
        h = self.base(x, layers=['fc7'])
        y = self.fc(h['fc7'])
        return y

例えば、10クラスに置き換えた場合には、以下のようにして、10次元のベクトルを得られるように、レイヤーが追加されていることが確認できます。

model = L.Classifier(VGG16Model(out_size=10))

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.vgg.prepare(img)
x = x[np.newaxis]
model.predictor(x)
"""
variable([[ 0.38773811,  0.01801527, -0.16246426,  1.95145059,  0.69883728,
           -4.43975067,  1.65732336, -2.41602373, -0.59516203,  1.09255409]])
"""

また、ファインチューニングでは、学習でオプティマイザをセットする際に、学習済みのレイヤーの重みはあまり学習しないように、ハイパーパラメータを設定する場合が多いです。

これも問題によって指定の方法は様々ですが、個人的によくやっているのは、下記のように、学習済みのレイヤーのみ学習率を抑えることが多いです。

# 学習済みレイヤーの学習率を抑える    

model = L.Classifier(VGG16Model(out_size=10))
optimizer = chainer.optimizers.Adam(alpha=1e-4)
optimizer.setup(model)

for func_name in model.predictor.base._children:
    for param in model.predictor.base[func_name].params():
        param.update_rule.hyperparam.alpha *= 0.1

if gpu >= 0:
    chainer.cuda.get_device(gpu).use()
    model.to_gpu(gpu)

学習済みのレイヤーは学習させない(重みを完全に固定する)場合には、disable_update関数が使えます。

# 学習済みレイヤーの学習率を固定する

model = L.Classifier(VGG16Model(out_size=10))
optimizer = chainer.optimizers.Adam(alpha=1e-4)
optimizer.setup(model)

model.predictor.base.disable_update()

if gpu >= 0:
    chainer.cuda.get_device(gpu).use()
    model.to_gpu(gpu)

あとは普通に学習部分を実装すれば、ファインチューニングをさせることができます。

ResNet152

ResNetも同様に実装が可能です。

res5層、pool5層などのいずれかの出力を取って、新しくfc層を1〜3層追加します。

class ResNet152Model(chainer.Chain):

    def __init__(self, out_size):
        super(ResNet152Model, self).__init__(
            base = L.ResNet152Layers(),
            fc = L.Linear(None, out_size)
        )

    def __call__(self, x):
        h = self.base(x, layers=['pool5'])
        y = self.fc(h['pool5'])
        return y
model = L.Classifier(ResNet152Model(out_size=10))

img = Image.open('./pug.jpg')
x = chainer.links.model.vision.resnet.prepare(img)
x = x[np.newaxis]
model.predictor(x)
"""
variable([[-0.21243748,  0.13713685, -0.03266272, -0.27797008, -0.13169979,
           -0.22922461,  0.11408829,  0.25870982,  0.6897561 ,  0.35164833]])
"""

モデルインタンスの宣言後は、オプティマイザなどの設定は同じように実装できます。

# 学習済みレイヤーの学習率を抑える    

model = L.Classifier(ResNet152Model(out_size=10))
optimizer = chainer.optimizers.Adam(alpha=1e-4)
optimizer.setup(model)

for func_name in model.predictor.base._children:
    for param in model.predictor.base[func_name].params():
        param.update_rule.hyperparam.alpha *= 0.1

if gpu >= 0:
    chainer.cuda.get_device(gpu).use()
    model.to_gpu(gpu)
# 学習済みレイヤーの学習率を固定する

model = L.Classifier(ResNet152Model(out_size=10))
optimizer = chainer.optimizers.Adam(alpha=1e-4)
optimizer.setup(model)

model.predictor.base.disable_update()

if gpu >= 0:
    chainer.cuda.get_device(gpu).use()
    model.to_gpu(gpu)

GoogLeNet

GoogLeNetに関しては、上記でも述べましたが、出力までの推論がややこしく構成されています。

評価時は、loss1loss2loss3を使って計算させますが、予測はloss3(loss関数を通す前のベクトル)のみ利用します。

したがって、それぞれのlossに向かうfc層を変更して、同様に複数のlossを使って評価を行うモデルに変更する必要があると考えるのが自然です。

しかし、GoogLeNetの layers 引数に、loss1_fc1loss2_fc1が選択できないようになっています。(指定しても何も返ってこない)

したがって、現状では、同様のネットワークを自前で作成する必要があるみたいです。