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의 개발 속도를 보자면 좀더 전망이 있어 보이는 프레임웍이 아닐까 한다.

CC BY-NC 4.0 Gluon을 이용한 Grad CAM by from __future__ import dream is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.