전이학습(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()와 같은 트릭이 이들을 피하는데 많은 도움이 되었다.

Gluon을 이용한 Grad CAM

먼저 이 글을 독자분들이 읽기 전에 배경 지식을 위해 아래 글을 먼저 읽어보길 추천한다.

이전 글의 코드를 보면 알다시피 keras의 레이어 API구조로 직관적으로 gradient나 layer 출력을 직관적으로 뽑아보는게 다소 번거롭고 backend API의 도움으로 부분적으로나마 가능하다는 생각을 독자분들도 했을거라 생각한다. 아마도 이런 부분때문에 PyTorch와 같은 imperative 방식의 딥러닝 프레임웍이 인기가 있다고 생각한다. 왜냐면 흡사 numpy를 쓰듯이 프레임웍을 사용하면 되기 때문이다. 결정적으로 seq2seq 방식의 기계번역 모형을 keras로 만들면서 이 부분에 대한 절실함을 느꼈고(비주얼베이직 코드에 어셈블리어 코드를 심는 느낌…), 앞으로 뭔가 고도화되고 복잡하고 다이내믹한 모형을 빌드한다면 반드시 뭔가 대안이 필요할 것이라는 생각을 했다.

필자의 이번에 소개할 것은 Gluon이다. 사실 이 프레임웍을 사용하게 된 계기는 튜토리얼을 보고 흡사 Keras + PyTorch와 같은 느낌을 받았기 때문이다. 게다가 아직 버전이 낮음에도 불구하고 API가 생각보다 잘 정리되어 있다는 것과 HybridBlock으로 성능과 효율사이에서 사용자가 유연하게 선택할 수 있게 하는 기능도 보유하고 있기 때문이다.

일단 뭔가를 배우는 가장 빠른 방식은 해당 도구로 원하는 것을 직접 만들어 보는 것일 것인데, Gluon으로 얼마전에 만들어본 Grad CAM을 구현해 보도록 하겠다. 이전 글을 본 독자하면 앞의 코드는 건너뛰고 “학습, 테스트를 위한 DataLoader 정의” 부터 보길 바란다.

이 글의 목적은 필자의 Gluon 실습이며 동시에 keras와 유사한 결과를 보여준다면 어느정도 믿을만한 프레임웍이란 생각도 해볼 수 있는 근거마련을 하기 위함이다.

In [1]:
import pandas as pd
import numpy as np
from konlpy.tag import Mecab
from collections import Counter
import operator
import matplotlib.pyplot as plt
import numpy as np
import mxnet as mx
from mxnet import gluon, autograd
from mxnet.gluon import nn, rnn

%matplotlib inline

mecab = Mecab()

학습셋 전처리

In [2]:
tbl = pd.read_csv("nsmc/ratings_train.txt",sep='\t')

keywords = [mecab.morphs(str(i).strip()) for i in tbl['document']]

np.median([len(k) for k in keywords]), len(keywords), tbl.shape

keyword_cnt = Counter([i for item in keywords for i in item])
keyword_clip = sorted(keyword_cnt.items(), key=operator.itemgetter(1))[-5000:]
keyword_clip_dict = dict(keyword_clip)
keyword_dict = dict(zip(keyword_clip_dict.keys(), range(len(keyword_clip_dict))))
#공백과 미학습 단어 처리를 위한 사전 정보 추가  
keyword_dict['_PAD_'] = len(keyword_dict)
keyword_dict['_UNK_'] = len(keyword_dict) 
#키워드를 역추적하기 위한 사전 생성 
keyword_rev_dict = dict([(v,k) for k, v in keyword_dict.items()])
#리뷰 시퀀스 단어수의 중앙값 +5를 max 리뷰 시퀀스로 정함... 
max_seq =np.median([len(k) for k in keywords]) + 5
def encoding_and_padding(corp_list, dic, max_seq=50):
    from keras.preprocessing.sequence import pad_sequences
    coding_seq = [ [dic.get(j, dic['_UNK_']) for j in i]  for i in corp_list ]
    #일반적으로 리뷰는 마지막 부분에 많은 정보를 포함할 가능성이 많아 패딩은 앞에 준다. 
    return(pad_sequences(coding_seq, maxlen=max_seq, padding='pre', truncating='pre',value=dic['_PAD_']))

max_seq

train_x = encoding_and_padding(keywords, keyword_dict, max_seq=int(max_seq))
train_y = tbl['label']
train_x.shape, train_y.shape

학습, 테스트를 위한 DataLoader 정의

In [8]:
tr_idx = np.random.choice(train_x.shape[0], int(train_x.shape[0] * 0.9) )
tr_idx_set = set(tr_idx)
te_idx = np.array([i for i in range(train_x.shape[0]) if i not in tr_idx_set])
len(te_idx), len(tr_idx)

tr_set = gluon.data.ArrayDataset(train_x[tr_idx], train_y[tr_idx].as_matrix())
tr_data_iterator = gluon.data.DataLoader(tr_set, batch_size=100, shuffle=True)

te_set =gluon.data.ArrayDataset(train_x[te_idx], train_y[te_idx].as_matrix())
te_data_iterator = gluon.data.DataLoader(te_set, batch_size=100, shuffle=True)

type(train_y), type(train_x)
Out[8]:
(pandas.core.series.Series, numpy.ndarray)

네이버 무비 리뷰 분류 모형 with Gluon(MXNet)

In [33]:
class SentClassificationModel(gluon.Block):
    """무비 리뷰 분류 모형
    keras 모형과 같은 아키텍처로 구성했다. 
    """

    def __init__(self, vocab_size, num_embed, **kwargs):
        super(SentClassificationModel, self).__init__(**kwargs)
        with self.name_scope():
            self.embed = nn.Embedding(input_dim=vocab_size, output_dim=num_embed,
                                        weight_initializer = mx.init.Xavier())
            self.conv1 = nn.Conv1D(channels=32, kernel_size=1, padding=1)
            self.conv2 = nn.Conv1D(channels=16, kernel_size=2, padding=1)
            self.conv3 = nn.Conv1D(channels=8, kernel_size=3, padding=2)
            
            self.pool1 = nn.AvgPool1D()
            self.pool2 = nn.AvgPool1D()
            self.pool3 = nn.AvgPool1D()

            self.lstm = rnn.GRU(layout='NTC', hidden_size=10, dropout=0.2, bidirectional=True)
            self.dense = nn.Dense(units=1, activation='sigmoid')
            
            self.conv1_act = None
            
    def forward(self, inputs, get_act=False):
        em_out = self.embed(inputs)
        em_swaped = mx.nd.swapaxes(em_out, 1,2)
        conv1_ = self.conv1(em_swaped)
        if get_act:
            self.conv1_act = conv1_
            
        conv1_out = self.pool1(conv1_)
        conv2_ =self.conv2(em_swaped)
        conv2_out = self.pool2(conv2_)
        conv3_ =self.conv3(em_swaped)
        conv3_out = self.pool3(conv3_)
        cated_layer = mx.nd.concat(conv1_out, conv2_out, conv3_out , dim=1)
        cated_layer = mx.nd.swapaxes(cated_layer, 1,2)
        bigru_out  = self.lstm(cated_layer)
        outs = self.dense(bigru_out)
        return outs
In [54]:
ctx = mx.gpu()

#모형 인스턴스 생성 및 트래이너, loss 정의 
model = SentClassificationModel(vocab_size = len(keyword_dict), num_embed=50)
model.collect_params().initialize(mx.init.Xavier(), ctx=ctx)
#keras와 같은 값으로 learning_rate를 0.001로 했을 경우 모형이 오버피팅 되는 경향이 있어 줄였다. 
trainer = gluon.Trainer(model.collect_params(), 'rmsprop', optimizer_params={'learning_rate':0.0002})
loss = gluon.loss.SigmoidBCELoss(from_sigmoid=True)
In [55]:
#구조 출력 
print(model)
SentClassificationModel(
  (embed): Embedding(5002 -> 50, float32)
  (conv1): Conv1D(None -> 32, kernel_size=(1,), stride=(1,), padding=(1,))
  (conv2): Conv1D(None -> 16, kernel_size=(2,), stride=(1,), padding=(1,))
  (conv3): Conv1D(None -> 8, kernel_size=(3,), stride=(1,), padding=(2,))
  (pool1): AvgPool1D(size=(2,), stride=(2,), padding=(0,), ceil_mode=False)
  (pool2): AvgPool1D(size=(2,), stride=(2,), padding=(0,), ceil_mode=False)
  (pool3): AvgPool1D(size=(2,), stride=(2,), padding=(0,), ceil_mode=False)
  (lstm): GRU(None -> 30, NTC, dropout=0.2, bidirectional)
  (dense): Dense(None -> 1, Activation(sigmoid))
)

학습

In [56]:
def calculate_loss(model, data_iter, loss_obj, ctx=ctx):
    test_loss = []
    for i, (te_data, te_label) in enumerate(data_iter):
        te_data = te_data.as_in_context(ctx)
        te_label = te_label.as_in_context(ctx)
        with autograd.predict_mode():
            te_output = model(te_data)
            loss_te = loss_obj(te_output, te_label.astype('float32'))
        curr_loss = mx.nd.mean(loss_te).asscalar()
        test_loss.append(curr_loss)
    return(np.mean(test_loss))
In [57]:
epochs = 10


tot_test_loss = []
tot_train_loss = []
for e in range(epochs):
    train_loss = []
    #batch training 
    for i, (data, label) in enumerate(tr_data_iterator):
        data = data.as_in_context(ctx)
        label = label.as_in_context(ctx)
        with autograd.record():
            output = model(data)
            loss_ = loss(output, label.astype('float32'))
            loss_.backward()
        trainer.step(data.shape[0])
        curr_loss = mx.nd.mean(loss_).asscalar()
        train_loss.append(curr_loss)

    #caculate test loss
    test_loss = calculate_loss(model, te_data_iterator, loss_obj = loss, ctx=ctx) 

    print("Epoch %s. Train Loss: %s, Test Loss : %s" % (e, np.mean(train_loss), test_loss))    
    tot_test_loss.append(test_loss)
    tot_train_loss.append(np.mean(train_loss))
Epoch 0. Train Loss: 0.467143, Test Loss : 0.38506
Epoch 1. Train Loss: 0.366111, Test Loss : 0.375705
Epoch 2. Train Loss: 0.354542, Test Loss : 0.372504
Epoch 3. Train Loss: 0.348722, Test Loss : 0.370496
Epoch 4. Train Loss: 0.344557, Test Loss : 0.368375
Epoch 5. Train Loss: 0.340094, Test Loss : 0.366121
Epoch 6. Train Loss: 0.335008, Test Loss : 0.363967
Epoch 7. Train Loss: 0.328834, Test Loss : 0.361943
Epoch 8. Train Loss: 0.322123, Test Loss : 0.361348
Epoch 9. Train Loss: 0.31443, Test Loss : 0.356754
In [59]:
plt.plot(tot_train_loss)
plt.plot(tot_test_loss)
plt.title('model loss')
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train', 'valid'], loc='upper left')
plt.show()

GRAD CAM 구현 및 예제 확인


In [60]:
def grad_cam_conv1D(model, x, y, loss, ctx):
    with autograd.record():
        output = model.forward(mx.nd.array([x,],ctx=ctx),get_act=True)
        loss_ = loss(output, mx.nd.array([[y,]],ctx=ctx).astype('float32'))
    loss_.backward()
    acts = model.conv1_act
    pooled_grad = mx.nd.mean(model.conv1.weight.grad(), axis=(1,2))
    for i in range(acts.shape[1]):
        acts[:,i,:] *= pooled_grad[i]
    heat = mx.nd.mean(acts, axis=1)
    return(heat.asnumpy()[0][1:-1])
In [61]:
tbl_test = pd.read_csv("nsmc/ratings_test.txt",sep='\t')
parsed_text = [mecab.morphs(str(i).strip()) for i in tbl_test['document']]
test_x = encoding_and_padding(parsed_text, keyword_dict, max_seq=int(max_seq))
test_y = tbl_test['label']
In [62]:
test_x.shape, test_y.shape
Out[62]:
((50000, 19), (50000,))
In [63]:
prob = model(mx.nd.array(test_x, ctx=ctx))
In [64]:
prob_np = prob.asnumpy()
In [65]:
%reload_ext rpy2.ipython
In [66]:
%%R -i test_y -i prob_np
require(pROC)
plot(roc(test_y, prob_np), print.auc=TRUE)
In [67]:
idx = 90
In [68]:
prob_np[idx], tbl_test.iloc[idx], test_y[idx]
Out[68]:
(array([ 0.0202708], dtype=float32),
 id                                                   9912932
 document    로코 굉장히 즐겨보는데, 이 영화는 좀 별로였다. 뭔가 사랑도 개그도 억지스런 느낌..
 label                                                      0
 Name: 90, dtype: object,
 0)
In [69]:
heat = grad_cam_conv1D(model, test_x[idx], y=0, loss=loss, ctx=ctx)
In [70]:
hm_tbl = pd.DataFrame({'heat':heat, 'kw':[keyword_rev_dict[i] for i in test_x[idx] ]})
In [71]:
%%R -i hm_tbl
library(ggplot2)

ggplot(hm_tbl, aes(x=kw, y=heat)) + geom_bar(stat='identity')

결과가 “별로, 억지, 개그”와 같은 단어가 부정적인 리뷰에 기여하는것으로 도출되었으며, 예상했던 대로 keras의 결과와 유사하게 도출되었다.
코드를 보면 알겠지만, forword()함수에 인자를 줘 중간 convolution 결과를 맴버변수에 적재해 놓은 부분을 볼 수 있을 것이다.다소 직관적인 코드로 keras가 별도 백엔드 API를 이해해야 되는 것에 비하면 간편한 부분이라 생각한다.
PyTorch로 그냥 갈까 하다가도 최근 MXNet 행보를 보거나 Gluon의 개발 속도를 보자면 좀더 전망이 있어 보이는 프레임웍이 아닐까 한다.

Grad CAM을 이용한 딥러닝 모형 해석

모형의 해석은 실무적인 관점에서 생각보다 중요한 부분을 차지하고 있다. 가장 먼저 모형이 상식에 맞게 만들어 졌는지 확인하기 위한 용도로 활용 가능한데, 만일 상식에 기반해서 모형이 만들어 졌다면 오랜 기간 모형을 운영하는데 안정성을 유지해줄 가능성이 많다. 또한 모형 스코어에 대한 설명을 현업에서 요구하는 경우가 많은데, 이 경우 현업의 이해와 신뢰를 도모하는데 큰 역할을 해준다. 무엇보다 모형을 해석할 수 있게 되면, 이전에 알지 못했던 새로운 정보를 아는 경우도 많아 예측이 목적이 아닌 현실을 모델링 하는 용도로 모형을 이용하기도 한다.

딥러닝 모형은 대표적인 블랙박스 모형으로 인지되어 왔으나, 최근 필자의 경험을 빗대어 보자면 딥러닝 모형이 블랙박스 모형일 것이라는 선입견이 상당부분 없어진 상황이다. 이번 글에서 그러한 가능성을 영화 리뷰 분류 모형을 기반으로 설명해 보고자 한다.

사용한 데이터는 Naver sentiment movie corpus이며, 해당 레포지터리에서 제공하는 학습셋과 테스트셋을 그대로 사용했으며, 데이터에서 제공하는 레이블(1:긍정, 0:부정)을 기반으로 리뷰의 긍정, 부정을 예측하는 모형을 딥러닝 모형으로 구축할 예정이다.

이 글에서 기반으로 한 Grad CAM 논문은 이미지를 기반으로 설명하고 있으나, 최근 추세로 볼때 텍스트 모델링 영역의 문제들도 Convolution 기반의 기법이 많이 사용되고 있어 살짝 방향을 틀어 텍스트 분류 모형을 구축하고 이를 해석하는 시도를 해보고자 한다. Grad CAM 기법은 모형이 해당 리뷰가 긍정(혹은 부정)이라고 판단한 원인을 backpropagation 기반 필터 가중치와 컨볼루션 아웃풋 값을 이용해 각 엔트리에 스코어를 부여하는 방식이다. 컨볼루션 아웃풋이 뭔가를 결정하는 다양한 재료를 제공한다고 예를 들어보면, 필터 가중치는 클래스에 의존해 컨볼루션이 제공한 재료에 하이라이팅을 하는 역할을 수행하는 것이다.
이런 종류의 컨볼루션 시각화 방식은 실제로 gradient ascent 기법을 활용하는데, 오히려 어떤걸 최대화 할지에 대한 생각을 가지고 이해를 하면 조금 이해하기 쉽다. 실제로 딥러닝 학습에서 “gradient X -1 X  learnig_rate” 만큼의 가중치 델타 업데이트가 이뤄지는건 loss를 줄이기 위함인데, 역으로 시각화할 영역에 델타를 가중해준다면 우리가 보고 싶어하는 부분이 부각이 될 것이다. 종합해서 이야기 하자면 학습이 되는 과정을 역으로 이용해 모형을 해석하고자 하는게 Grad CAM계열의 핵심이다. 여기서는 모형에서 선택된 1D 컨볼루션의 개별 필터의 가중치를 구해 필터를 통해 나온 결과에 가중해줘 개별 단어 엔트리가 클래스에 어느정도 중요하게 사용되었는지 확인하려고 한다. 이를 통해 모형이 상식적으로 모델링 되었는지 확인할 수 있으며, 더 나아가 모델을 디버깅하는데 도움이 될 수 있을 것이다.

In [1]:
import pandas as pd
import numpy as np
from konlpy.tag import Mecab
from collections import Counter
import operator
import matplotlib.pyplot as plt
%matplotlib inline

mecab = Mecab()
In [2]:
tbl = pd.read_csv("ratings_train.txt",sep='\t')
In [3]:
tbl.iloc[:10]
Out[3]:
id document label
0 9976970 아 더빙.. 진짜 짜증나네요 목소리 0
1 3819312 흠…포스터보고 초딩영화줄….오버연기조차 가볍지 않구나 1
2 10265843 너무재밓었다그래서보는것을추천한다 0
3 9045019 교도소 이야기구먼 ..솔직히 재미는 없다..평점 조정 0
4 6483659 사이몬페그의 익살스런 연기가 돋보였던 영화!스파이더맨에서 늙어보이기만 했던 커스틴 … 1
5 5403919 막 걸음마 뗀 3세부터 초등학교 1학년생인 8살용영화.ㅋㅋㅋ…별반개도 아까움. 0
6 7797314 원작의 긴장감을 제대로 살려내지못했다. 0
7 9443947 별 반개도 아깝다 욕나온다 이응경 길용우 연기생활이몇년인지..정말 발로해도 그것보단… 0
8 7156791 액션이 없는데도 재미 있는 몇안되는 영화 1
9 5912145 왜케 평점이 낮은건데? 꽤 볼만한데.. 헐리우드식 화려함에만 너무 길들여져 있나? 1

키워드 사전 구성 및 전처리

In [4]:
keywords = [mecab.morphs(str(i).strip()) for i in tbl['document']]
In [5]:
np.median([len(k) for k in keywords]), len(keywords), tbl.shape
Out[5]:
(14.0, 150000, (150000, 3))
In [7]:
keyword_cnt = Counter([i for item in keywords for i in item])
In [8]:
#간단하게 진행하기 위해 가장 빈도수가 많은 상위 5000개의 키워드만 사용한다. 
keyword_clip = sorted(keyword_cnt.items(), key=operator.itemgetter(1))[-5000:]
In [9]:
keyword_clip_dict = dict(keyword_clip)
keyword_dict = dict(zip(keyword_clip_dict.keys(), range(len(keyword_clip_dict))))
In [10]:
#공백과 미학습 단어 처리를 위한 사전 정보 추가  
keyword_dict['_PAD_'] = len(keyword_dict)
keyword_dict['_UNK_'] = len(keyword_dict) 
In [11]:
#키워드를 역추적하기 위한 사전 생성 
keyword_rev_dict = dict([(v,k) for k, v in keyword_dict.items()])
In [12]:
#리뷰 시퀀스 단어수의 중앙값 +5를 max 리뷰 시퀀스로 정함... 
max_seq =np.median([len(k) for k in keywords]) + 5
In [13]:
def encoding_and_padding(corp_list, dic, max_seq=50):
    from keras.preprocessing.sequence import pad_sequences
    coding_seq = [ [dic.get(j, dic['_UNK_']) for j in i]  for i in corp_list ]
    #일반적으로 리뷰는 마지막 부분에 많은 정보를 포함할 가능성이 많아 패딩은 앞에 준다. 
    return(pad_sequences(coding_seq, maxlen=max_seq, padding='pre', truncating='pre',value=dic['_PAD_']))
In [ ]:
train_x = encoding_and_padding(keywords, keyword_dict, max_seq=int(max_seq))
In [15]:
train_y = tbl['label']
In [16]:
train_x.shape, train_y.shape
Out[16]:
((150000, 19), (150000,))

모델링

임베딩을 별도 학습시켜서 진행할 수 있었으나, 포스트 목적상 간단하게 진행했다. 드롭아웃이 가미된 Bidirectional GRU가 성능향상에 가장 도움이 된거 같다.

In [17]:
from keras.models import *
from keras.layers import *
from keras.utils import *
from keras.optimizers import *
from keras.callbacks import *
from keras.layers import merge
from keras.layers.core import *
from keras.layers.recurrent import LSTM
from keras.models import *
import keras.backend as K
In [18]:
x_dim = train_x.shape[1]
In [19]:
inputs = Input(shape=(train_x.shape[1],), name='input')
In [20]:
embeddings_out = Embedding(input_dim=len(keyword_dict) , output_dim=50,name='embedding')(inputs)
In [21]:
conv0 = Conv1D(32, 1, padding='same')(embeddings_out)
conv1 = Conv1D(16, 2, padding='same')(embeddings_out)
conv2 = Conv1D(8, 3, padding='same')(embeddings_out)
In [22]:
pool0 = AveragePooling1D()(conv0)
pool1 = AveragePooling1D()(conv1)
pool2 = AveragePooling1D()(conv2)
In [23]:
concat_layer = concatenate([pool0, pool1, pool2],axis=2)
In [24]:
bidir =Bidirectional(GRU(10, recurrent_dropout=0.2, dropout=0.2))(concat_layer)
In [25]:
out = Dense(1,activation='sigmoid')(bidir)
In [26]:
model = Model(inputs=[inputs,], outputs=out)
In [27]:
model.summary()
__________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                     
==================================================================================================
input (InputLayer)              (None, 19)           0                                            
__________________________________________________________________________________________________
embedding (Embedding)           (None, 19, 50)       250100      input[0][0]                      
__________________________________________________________________________________________________
conv1d_1 (Conv1D)               (None, 19, 32)       1632        embedding[0][0]                  
__________________________________________________________________________________________________
conv1d_2 (Conv1D)               (None, 19, 16)       1616        embedding[0][0]                  
__________________________________________________________________________________________________
conv1d_3 (Conv1D)               (None, 19, 8)        1208        embedding[0][0]                  
__________________________________________________________________________________________________
average_pooling1d_1 (AveragePoo (None, 9, 32)        0           conv1d_1[0][0]                   
__________________________________________________________________________________________________
average_pooling1d_2 (AveragePoo (None, 9, 16)        0           conv1d_2[0][0]                   
__________________________________________________________________________________________________
average_pooling1d_3 (AveragePoo (None, 9, 8)         0           conv1d_3[0][0]                   
__________________________________________________________________________________________________
concatenate_1 (Concatenate)     (None, 9, 56)        0           average_pooling1d_1[0][0]        
                                                                 average_pooling1d_2[0][0]        
                                                                 average_pooling1d_3[0][0]        
__________________________________________________________________________________________________
bidirectional_1 (Bidirectional) (None, 20)           4020        concatenate_1[0][0]              
__________________________________________________________________________________________________
dense_1 (Dense)                 (None, 1)            21          bidirectional_1[0][0]            
==================================================================================================
Total params: 258,597
Trainable params: 258,597
Non-trainable params: 0
__________________________________________________________________________________________________
In [28]:
model.compile(optimizer='rmsprop', loss='binary_crossentropy')
In [29]:
hist = model.fit(x=train_x,y=train_y, batch_size=100, epochs=10, validation_split=0.1)
Train on 135000 samples, validate on 15000 samples
Epoch 1/10
135000/135000 [==============================] - 32s 234us/step - loss: 0.4070 - val_loss: 0.3630
Epoch 2/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3550 - val_loss: 0.3485
Epoch 3/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3359 - val_loss: 0.3400
Epoch 4/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3248 - val_loss: 0.3374
Epoch 5/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3160 - val_loss: 0.3371
Epoch 6/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3091 - val_loss: 0.3330
Epoch 7/10
135000/135000 [==============================] - 17s 129us/step - loss: 0.3030 - val_loss: 0.3327
Epoch 8/10
135000/135000 [==============================] - 17s 128us/step - loss: 0.2982 - val_loss: 0.3321
Epoch 9/10
135000/135000 [==============================] - 17s 128us/step - loss: 0.2927 - val_loss: 0.3287
Epoch 10/10
135000/135000 [==============================] - 17s 128us/step - loss: 0.2878 - val_loss: 0.3339
In [31]:
plt.plot(hist.history['loss'])
plt.plot(hist.history['val_loss'])
plt.title('model loss')
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train', 'valid'], loc='upper left')
plt.show()

테스트셋 만들기

In [33]:
tbl_test = pd.read_csv("ratings_test.txt",sep='\t')
In [34]:
parsed_text = [mecab.morphs(str(i).strip()) for i in tbl_test['document']]
In [35]:
test_x = encoding_and_padding(parsed_text, keyword_dict, max_seq=int(max_seq))
In [36]:
test_y = tbl_test['label']
In [37]:
test_x.shape, test_y.shape
Out[37]:
((50000, 19), (50000,))
In [38]:
prob = model.predict(test_x)
In [39]:
#개인적으로 모형 퍼포먼스 시각화는 R을 주로 활용하는편이다... ^^;..AUC 0.93정도로 꽤 좋은 성능이다.
%reload_ext rpy2.ipython
In [41]:
%%R -i test_y -i prob
require(pROC)
plot(roc(test_y, prob), print.auc=TRUE)

키워드 중요도 추출

In [60]:
def grad_cam_conv1D(model, layer_nm, x, sample_weight=1,  keras_phase=0):
    import keras.backend as K
    import numpy as np
    
    #레이어 이름에 해당되는 레이어 정보를 가져옴 
    layers_wt = model.get_layer(layer_nm).weights
    layers = model.get_layer(layer_nm)
    layers_weights = model.get_layer(layer_nm).get_weights()
    
    #긍정 클래스를 설명할 수 있게 컨볼루션 필터 가중치의 gradient를 구함  
    grads = K.gradients(model.output[:,0], layers_wt)[0]
    
    #필터별로 가중치를 구함 
    pooled_grads = K.mean(grads, axis=(0,1))
    get_pooled_grads = K.function([model.input,model.sample_weights[0], K.learning_phase()], 
                         [pooled_grads, layers.output[0]])
    
    pooled_grads_value, conv_layer_output_value = get_pooled_grads([[x,], [sample_weight,], keras_phase])
    #다시한번 이야기 하지만 loss를 줄이기 위한 학습과정이 아니다... 
    for i in range(conv_layer_output_value.shape[-1]):
        conv_layer_output_value[:, i] *= pooled_grads_value[i]
    
    heatmap = np.mean(conv_layer_output_value, axis=-1)
    return((heatmap, pooled_grads_value))
In [68]:
# test셋에서 90번째 인덱스에 해당하는 데이터를 시각화 해본다. 
idx = 90
In [69]:
prob[idx], tbl_test.iloc[idx], test_y[idx]
Out[69]:
(array([ 0.01689465], dtype=float32),
 id                                                   9912932
 document    로코 굉장히 즐겨보는데, 이 영화는 좀 별로였다. 뭔가 사랑도 개그도 억지스런 느낌..
 label                                                      0
 Name: 90, dtype: object,
 0)
In [70]:
hm, graded = grad_cam_conv1D(model, 'conv1d_1', x=test_x[idx])
In [71]:
hm_tbl = pd.DataFrame({'heat':hm, 'kw':[keyword_rev_dict[i] for i in test_x[idx] ]})
In [72]:
%%R -i hm_tbl
library(ggplot2)
library(extrafont)

ggplot(hm_tbl, aes(x=kw, y=heat)) + geom_bar(stat='identity') + theme_bw(base_family = 'UnDotum')

위 결과를 해석해보면 “개그, 뭔가, 억지”와 같은 단어가 해당 리뷰가 긍정을 판단하게 하는데 부정적인 역할을 수행하고 있다는 것을 알 수 있다. 반면 ‘사랑’이라는 단어는 긍정을 판단하게 하는 역할을 수행했으나, 결과적으로 종합 스코어 상 부정 클래스에 속하게 되었다.
위의 결과에서 보면 조사가 다소 노이즈성을 가지는 것을 볼 수 있는데, 조사를 제거하면서 유의미한 키워드셋을 더 늘리는 방안도 해석력과 모델 성능을 위해 고민해볼 필요가 있다고 생각된다.

 


간단하게 Grad CAM을 이용해 딥러닝 모형 해석을 시도해 보았다. 물론 위 코드는 단일의 레코드에 대한 설명을 하고 있는데, 다수의 긍정, 부정 리뷰를 랜덤으로 샘플링해 위와 같은 단어 가중치를 뽑아 통계를 내보면 영화 리뷰에서 긍정이라 판단되는 단어들과 부정이라 생각되는 단어 리스트를 얻을 수도 있을 것이다.  물론 이 단어는 모형에서 판단하기에 부정, 긍정을 결정하는 단어가 될 것이며, 이것들이 모형에서 이야기하는 중요 키워드라 할 수 있을 것이다.