전이학습(transfer learning)으로 모형 재사용하기 (Gluon 기반)

전이학습(traansfer learning)은 꽤 오래된 주제지만 실무에서 활용한만한 가치가 높은 기법중에 하나이다. 특히나 이미지 관련 분류 모형에서는 이 기법은 거의 필수라고 생각하고 일반적인 기술일 것이다. 이 기법은 기존 분류에 대한 성능이 좋은 네트워크와 가중치를 일부 혹은 전부 가져와 상위 레이어를 튜닝하는 방식으로 자신이 원하는 문제를 풀게 모형을 재활용 혹은 전용하는 방법이다.
특히나 학습셋이 부족한 경우 그리고 문제영역 자체가 세부 영역으로 좁혀진 경우 매우 활용 가치가 높다.

필자가 최근에 관심있어하는 Gluon을 기반으로 작업을 진행했다. 프레임웍에 대해서 개인적으로 테스트 하는 과정의 일환이고, 익숙해지기 위한 과정인데, 혹여나 비슷한 시행착오를 하는 독자를 위해 최대한 상세하게 주석과 레퍼런스를 달아두었다. Gluon이 고작 0.1 버전을 유지하고 있지만, 필자가 익숙해지고자 하는 이유는 PyTorch보다 API구조가 더 잘 되어 있다는 것과, PyTorch의 유연함과 더불어 Keras의 간결합을 가지고 있어서이다. 매우 개인적인 이유이지만 1년전 MXNet을 실무에서 활용하려다 못한 약간은 한풀이 느낌도 있긴 하다.

필자가 API튜토리얼 문서Github을 모두 참고하면서 기존 이미지 분류 모형을 기반으로 상위에 레이어를 추가해 전이학습을 수행하는 과정을 진행해 나가고자 한다. 아마도 Gluon기반으로 비슷한 작업을 해보신 분들은 많은 시행착오를 했을 부분이라 생각한다. 필자도 많은 시행착오를 했지만 그 시행착오 덕분에 내부 구조나 API를 더 충실히 확인할 수 있었다.

데이터는 kaggle에서 획득한 train.zip을 활용했다. 이 노트북이 존재하는 경로에 그대로 압축을 풀어서 작업을 수행했다.

아래와 같은 과정으로 진행된다.

  1. jpg를 Image RecordIO dataset 파일로 전환 이 문서를 주로 참고했다.
  2. 모형 아키텍처 정의 (vgg16 기반)
  3. 학습/평가
  4. 예제 적용

아마도 이 주제에 대해서 모형을 구축해본 분들이 있을지 모르겠는데, 해당 모형을 전이학습이 아닌 모형으로 전이학습이 된 모형정도의 성능을 나오게 하는건 매우 어렵다. 필자의 경험으로 fully connected 와 convolution 몇개로 모형을 구축했을때 80%의 분류 정확도에서 더 올리는건 어려웠던 경험이 있다.

학습 데이터 생성

  • 먼저 kaggle에서 학습셋을 다운 받아 로컬에 압축을 풀어준다.
In [5]:
import pandas as pd
import numpy as np
from sklearn.utils import shuffle
import mxnet as mx
from mxnet import gluon
from mxnet.gluon import nn
from mxnet.image import color_normalize
import mxnet.autograd as autograd
from mxnet.gluon.model_zoo import vision as models
In [2]:
#더미 파일 리스트를 구축한다. , im2rec.py는 https://github.com/apache/incubator-mxnet/blob/master/tools/im2rec.py 
%run im2rec.py --list list catsdogs_full train
In [3]:
#아래 보는것과 같이 텝으로 구분되어서 파일 인덱스, 레이블(클래스), 사진파일명 정보가 구성된다. 
!head catsdogs_full.lst
3197	0.000000	cat.1625.jpg
15084	0.000000	dog.12322.jpg
1479	0.000000	cat.11328.jpg
5262	0.000000	cat.3484.jpg
20714	0.000000	dog.6140.jpg
9960	0.000000	cat.7712.jpg
945	0.000000	cat.10848.jpg
15585	0.000000	dog.1524.jpg
12376	0.000000	cat.9888.jpg
4367	0.000000	cat.2679.jpg
In [6]:
#pandas 테이블로 파일을 읽어들여 레이블을 마킹하고 랜덤 셔플한 뒤 학습과 테스트셋으로 만든다. 
catdoglist = pd.read_csv('catsdogs_full.lst',sep='\t', names=['idx', 'class', 'fn'])

catdoglist['class'] = [1  if i else 0 for i in catdoglist['fn'].str.contains('dog')]

catdoglist = shuffle(catdoglist)

train = catdoglist.iloc[:6500, ]

train.shape

test = catdoglist.iloc[6500:, ]

train.to_csv('catsdogs_train.lst',doublequote=False,sep='\t', header=False, index=False)
test.to_csv('catsdogs_test.lst',doublequote=False,sep='\t', header=False, index=False)
In [165]:
#실제 Image RecordIO dataset을 만든다. 
!python im2rec.py --num-thread 10 catsdogs_t train
/home/gogamza/python_3.6/lib/python3.6/distutils/__init__.py:4: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  import imp
Creating .rec file from /home/gogamza/work/gluons/catsdogs_train.lst in /home/gogamza/work/gluons
time: 0.03124403953552246  count: 0
time: 1.179645299911499  count: 1000
time: 0.976043701171875  count: 2000
time: 1.1002657413482666  count: 3000
time: 1.051544189453125  count: 4000
time: 1.0289239883422852  count: 5000
time: 0.5287754535675049  count: 6000
Creating .rec file from /home/gogamza/work/gluons/catsdogs_test.lst in /home/gogamza/work/gluons
time: 0.18526077270507812  count: 0
time: 0.9183506965637207  count: 1000
time: 0.8911347389221191  count: 2000
time: 1.0432755947113037  count: 3000
time: 0.8570001125335693  count: 4000
time: 1.0042638778686523  count: 5000
time: 0.9739773273468018  count: 6000
time: 0.8833253383636475  count: 7000
time: 0.9417126178741455  count: 8000
time: 1.2968952655792236  count: 9000
time: 1.1031651496887207  count: 10000
time: 1.019164800643921  count: 11000
time: 0.9050683975219727  count: 12000
time: 0.9271485805511475  count: 13000
time: 0.8212544918060303  count: 14000
time: 0.8985750675201416  count: 15000
time: 1.2766637802124023  count: 16000
time: 0.721259355545044  count: 17000
time: 0.48262739181518555  count: 18000
[17:43:09] src/engine/engine.cc:55: MXNet start using engine: NaiveEngine
[17:43:09] src/engine/naive_engine.cc:55: Engine shutdown
In [7]:
#학습은 GPU에서 
ctx = mx.gpu(0)

모형 생성

In [148]:
class cats_and_dogs(gluon.HybridBlock):
    def __init__(self, num_class, num_hidden , **kwargs):
        super(cats_and_dogs, self).__init__(**kwargs)
        
        with self.name_scope():
            #model zoo에서 vgg16 모형을 가져온다. 
            #이렇게 vgg를 클래스 내부에서 로딩하면 인스턴스마다 context, prefix 가 통일되게 생성되어 모형 저장/로딩시 용이한 측면이 있다.  
            self.vgg_net =  models.vgg16(pretrained=True, root=".models").features
            self.classifier = nn.HybridSequential()
            with self.classifier.name_scope():
                self.classifier.add(nn.Dense(num_hidden, activation="relu"))
                self.classifier.add(nn.Dense(int(num_hidden/2), activation="relu"))
                self.classifier.add(nn.Dense(units=num_class))            
    
    def hybrid_forward(self, F, inputs):
        o_vgg = self.vgg_net(inputs)
        pred = self.classifier(o_vgg)
        return pred
        
        
        
In [149]:
model = cats_and_dogs(num_class=2, num_hidden = 500)
In [137]:
#Symbolic 텐서로 컴파일한다. 
#확실히 GPU메모리 사용량은 줄어드나, 학습 속도가 2배 넘게 빨리지지는 않는거 같다. 
model.hybridize()
In [150]:
print(model)
cats_and_dogs(
  (vgg_net): HybridSequential(
    (0): Conv2D(3 -> 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): Activation(relu)
    (2): Conv2D(64 -> 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): Activation(relu)
    (4): MaxPool2D(size=(2, 2), stride=(2, 2), padding=(0, 0), ceil_mode=False)
    (5): Conv2D(64 -> 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (6): Activation(relu)
    (7): Conv2D(128 -> 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (8): Activation(relu)
    (9): MaxPool2D(size=(2, 2), stride=(2, 2), padding=(0, 0), ceil_mode=False)
    (10): Conv2D(128 -> 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (11): Activation(relu)
    (12): Conv2D(256 -> 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (13): Activation(relu)
    (14): Conv2D(256 -> 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (15): Activation(relu)
    (16): MaxPool2D(size=(2, 2), stride=(2, 2), padding=(0, 0), ceil_mode=False)
    (17): Conv2D(256 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (18): Activation(relu)
    (19): Conv2D(512 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (20): Activation(relu)
    (21): Conv2D(512 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (22): Activation(relu)
    (23): MaxPool2D(size=(2, 2), stride=(2, 2), padding=(0, 0), ceil_mode=False)
    (24): Conv2D(512 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (25): Activation(relu)
    (26): Conv2D(512 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (27): Activation(relu)
    (28): Conv2D(512 -> 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (29): Activation(relu)
    (30): MaxPool2D(size=(2, 2), stride=(2, 2), padding=(0, 0), ceil_mode=False)
    (31): Dense(25088 -> 4096, Activation(relu))
    (32): Dropout(p = 0.5)
    (33): Dense(4096 -> 4096, Activation(relu))
    (34): Dropout(p = 0.5)
  )
  (classifier): HybridSequential(
    (0): Dense(None -> 500, Activation(relu))
    (1): Dense(None -> 250, Activation(relu))
    (2): Dense(None -> 2, linear)
  )
)

학습

In [151]:
#생성한 rec 파일로 이터레이터를 생성한다. 
#Tensorflow와 케라스에서와는 다르게 채널이 먼저 나온다.
#이미지 학습을 하는데 ImageRecordIter는 거의 필수라고 생각하는데, 이는 다양한 data augmentation의 옵션을 제공하기 때문이다.
batch_size = 50  # GPU메모리 크기에 따라 정해준다. 
valid_batch_size = 30 #validation은 적어도 큰 문제가 없다. 
train_iter = mx.io.ImageRecordIter(path_imgrec='catsdogs_train.rec',
                                   min_img_size=256,
                                   data_shape=(3, 224, 224),
                                   rand_crop=True,
                                   shuffle=True,
                                   batch_size=batch_size,
                                   max_random_scale=1.5,
                                   min_random_scale=0.75,
                                   rand_mirror=True)
val_iter = mx.io.ImageRecordIter(path_imgrec="catsdogs_test.rec",
                                 min_img_size=256,
                                 data_shape=(3, 224, 224),
                                 batch_size=valid_batch_size)
In [152]:
def evaluate(net, data_iter, ctx):
    data_iter.reset()
    acc = mx.metric.Accuracy()
    for batch in data_iter:
        mx.nd.waitall()
        #reference 
        #https://mxnet.incubator.apache.org/api/python/gluon/model_zoo.html
        data = color_normalize(batch.data[0]/255,
                               mean=mx.nd.array([0.485, 0.456, 0.406]).reshape((1,3,1,1)),
                               std=mx.nd.array([0.229, 0.224, 0.225]).reshape((1,3,1,1)))
        
        data = data.as_in_context(ctx)
        label = batch.label[0].as_in_context(ctx)
        with autograd.predict_mode():
            output = net(data)
        predictions = mx.nd.argmax(output, axis=1)
        acc.update(preds=predictions, labels=label)
        mx.ndarray.waitall()
    return acc.get()[1]
In [155]:
import mxnet.autograd as autograd
from mxnet import gluon
import logging
import os
import time
import numpy as np
logging.basicConfig(level=logging.INFO)

def train(net, train_iter, val_iter, epochs, ctx, need_init=False):
    if need_init:
        #vgg16 모형은 학습을 fix 한다. 
        # https://github.com/apache/incubator-mxnet/issues/1340
        net.vgg_net.collect_params().setattr('grad_req', 'null')
        net.classifier.collect_params().initialize(mx.init.Xavier(), ctx=ctx)
    #변수들이 모두 같은 ctx에 있는지 다시 확인 
    net.collect_params().reset_ctx(ctx)

    trainer = gluon.trainer.Trainer(net.collect_params(), 'rmsprop', optimizer_params={'learning_rate':0.001})
    loss = gluon.loss.SoftmaxCrossEntropyLoss(sparse_label=False)


    val_accs = evaluate(net, val_iter, ctx)
    logging.info('[Initial] validation accuracy : {}'.format(val_accs))
    tr_loss = []
    val_accs = []
    for epoch in range(epochs):
        tic = time.time()
        train_iter.reset()
        btic = time.time()
        loss_seq = []
        for i, batch in enumerate(train_iter):
            #속도를 좀 감소시키더라도 GPU 메모리에 대한 관리를 더 한다.  
            mx.nd.waitall()
            data = color_normalize(batch.data[0]/255,
                                   mean=mx.nd.array([0.485, 0.456, 0.406]).reshape((1,3,1,1)),
                                   std=mx.nd.array([0.229, 0.224, 0.225]).reshape((1,3,1,1)))
            data = data.as_in_context(ctx)
            label = mx.nd.one_hot(batch.label[0].as_in_context(ctx),2)
            with autograd.record():
                z = net(data)
                L = loss(z, label)
                L.backward()
            trainer.step(data.shape[0])
            btic = time.time()
            curr_loss = mx.nd.mean(L).asscalar()
            loss_seq.append(curr_loss)
            mx.ndarray.waitall()
        logging.info('[Epoch {}] training loss : {}'.format(epoch, np.mean(loss_seq)))
        logging.info('[Epoch %d] time cost: %f'%(epoch, time.time()-tic))
        val_acc = evaluate(net, val_iter, ctx)
        logging.info('[Epoch %d] validation accuracy : %s'%(epoch, val_acc))
        val_accs.append(val_acc)
        tr_loss.append(np.mean(loss_seq))
    return(val_accs, tr_loss)
In [156]:
accs, losses = train(model,train_iter, val_iter, 10, ctx=ctx, need_init=True)
INFO:root:[Initial] validation accuracy : 0.5424635332252836
INFO:root:[Epoch 0] training loss : 0.1768229603767395
INFO:root:[Epoch 0] time cost: 33.588889
INFO:root:[Epoch 0] validation accuracy : 0.977525661804
INFO:root:[Epoch 1] training loss : 0.14089804887771606
INFO:root:[Epoch 1] time cost: 34.197274
INFO:root:[Epoch 1] validation accuracy : 0.979653679654
INFO:root:[Epoch 2] training loss : 0.12714947760105133
INFO:root:[Epoch 2] time cost: 34.241362
INFO:root:[Epoch 2] validation accuracy : 0.979038357645
INFO:root:[Epoch 3] training loss : 0.12572327256202698
INFO:root:[Epoch 3] time cost: 34.283421
INFO:root:[Epoch 3] validation accuracy : 0.972987574284
INFO:root:[Epoch 4] training loss : 0.12414756417274475
INFO:root:[Epoch 4] time cost: 34.316082
INFO:root:[Epoch 4] validation accuracy : 0.980194805195
INFO:root:[Epoch 5] training loss : 0.11750277131795883
INFO:root:[Epoch 5] time cost: 34.442768
INFO:root:[Epoch 5] validation accuracy : 0.980064829822
INFO:root:[Epoch 6] training loss : 0.12463101744651794
INFO:root:[Epoch 6] time cost: 34.416495
INFO:root:[Epoch 6] validation accuracy : 0.979200432199
INFO:root:[Epoch 7] training loss : 0.1226000264286995
INFO:root:[Epoch 7] time cost: 34.490258
INFO:root:[Epoch 7] validation accuracy : 0.979761904762
INFO:root:[Epoch 8] training loss : 0.11914137005805969
INFO:root:[Epoch 8] time cost: 34.639952
INFO:root:[Epoch 8] validation accuracy : 0.98038897893
INFO:root:[Epoch 9] training loss : 0.11623886972665787
INFO:root:[Epoch 9] time cost: 34.496284
INFO:root:[Epoch 9] validation accuracy : 0.98076715289

분류 예제

In [157]:
from skimage.color import rgba2rgb
from skimage import io
from matplotlib import pyplot as plt
%matplotlib inline

#cpu에서 동작 시킨다.  
model.collect_params().reset_ctx(mx.cpu(0))

#reference : http://gluon.mxnet.io/chapter08_computer-vision/fine-tuning.html
def classify_dogcat(net, url):
    I = io.imread(url)
    if I.shape[2] == 4:
        I = rgba2rgb(I)
    image = mx.nd.array(I).astype(np.uint8)
    plt.subplot(1, 2, 1)
    plt.imshow(image.asnumpy())
    image = mx.image.resize_short(image, 256)
    image, _ = mx.image.center_crop(image, (224, 224))
    plt.subplot(1, 2, 2)
    plt.imshow(image.asnumpy())
    image = mx.image.color_normalize(image.astype(np.float32)/255,
                                     mean=mx.nd.array([0.485, 0.456, 0.406]),
                                     std=mx.nd.array([0.229, 0.224, 0.225]))
    image = mx.nd.transpose(image.astype('float32'), (2,1,0))
    image = mx.nd.expand_dims(image, axis=0)
    out = mx.nd.SoftmaxActivation(net(image))
    print('Probabilities are: '+str(out[0].asnumpy()))
    result = np.argmax(out.asnumpy())
    outstring = ['cat!', 'dog!']
    print(outstring[result])
In [169]:
%timeit
classify_dogcat(net=model, url="cats.jpg")
Probabilities are: [  9.99972343e-01   2.76759565e-05]
cat!
In [158]:
%timeit
classify_dogcat(net=model, url="Cute-dog-listening-to-music-1_1.jpg")
Probabilities are: [  3.01063788e-04   9.99698997e-01]
dog!

모형 저장/로딩

In [ ]:
model.save_params("cats_and_dogs_model.mdl")
In [160]:
new_model = cats_and_dogs(num_class=2, num_hidden = 500)
In [163]:
new_model.load_params('cats_and_dogs_model.mdl', ctx=mx.cpu(0))
In [164]:
%timeit
classify_dogcat(net=new_model, url="Cute-dog-listening-to-music-1_1.jpg")
Probabilities are: [  3.01063788e-04   9.99698997e-01]
dog!

모형을 로딩해서 다시 예측을 해도 같은 결과를 보여준다..(너무 당연하지만, 혹시나 하는…)

정리

일단 10번의 에폭만으로도 98% 이상의 테스트셋 정확도를 가져오는 것을 확인할 수 있었다. 일반적으로 이 문제에서 전이학습으로 달성 가능한 성능에는 근접해서, 이런 저런 개인적 실험은 성공한 것으로 판단한다.

사실 관련 토픽에 대한 매우 훌륭한 튜토리얼이 존재하는데 다소 어렵기도 하고, 복잡한 단점이 있으며 실제 가장 많이 활용되는 방법이라고 보기 어려운 전이학습 방식이다. 실무에서는 하위 레이어에 대한 학습을 고정하고 상위 N레이어를 교체하는 방식으로 활용하는데, 이를 위해 Gluon의 특정 레이어의 학습을 고정하는 방법을 확인할 필요가 있었고, 모형 저장/로딩 방식을 전이학습에서 어떻게 가져가야 될지에 대한 실제 활용단의 고민이 필요했다.

개인적으로 Gluon의 클래스 구조를 확인해본 흥미로운 경험이었는데, 구조를 확인하면서 다양한 옵션을 설정할 수 있고, 확인해볼 수 있어서 개인 연구용으로 활용 가능한 프레임웍이라는 생각이 들었다.

이 실습을 하면서 GPU 메모리 오버플로가 일어나면서 프로세스가 죽는 경우가 있었는데, export MXNET_ENGINE_TYPE=NaiveEngine와 같은 환경변수 설정과 배치 사이에 mx.ndarray.waitall()와 같은 트릭이 이들을 피하는데 많은 도움이 되었다.

CC BY-NC 4.0 전이학습(transfer learning)으로 모형 재사용하기 (Gluon 기반) by from __future__ import dream is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.