• Home

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

 

 

 

딥러닝 한글 자동띄어쓰기 모형 성능 향상 및 API 업데이트

1차 모형과 띄어쓰기 정확도 성능 차이

테스트 셋 1차 모형 2차 모형
세종 코퍼스 94.8% 97.1%
구어체 코퍼스 93.2% 94.3%

성능 측정방식은 코퍼스 내 문장별로 모든 띄어쓰기를 제거하고 넣었을때 올바르게 띄어쓰기가 되는지 여부를 측정한 것이다. 세종 코퍼스 1만 문장, 구어체 코퍼스 3만 문장으로 테스트 했다. 그리고 모형 학습은 박찬엽씨가 공유해준 뉴스 코퍼스 1억 문장 기반으로 했다.

금번 API 업데이트의 핵심은 물론 정확도 향상이다. 몇몇 레이어의 추가가 핵심적인 역할을 수행했다.

딥러닝 아키텍처라는게 일종의 “어떠 어떠한 패턴을 뽑아줄 수 있을 것이다”라는 가능성을 내포하는데, 몇몇 실험을 통해 그 가능성을 크게 해준게 효과적으로 적용되었던 것으로 본다.

GPU 두장을 거의 이틀에 하루꼴로 구동시키며 실험했는데, 이를 통해 얻은 경험치는 아래와 같다.

  1. 될성부른 모형은 초기 epoc 성능부터 다르다.
  2. 데이터를 더 많이 넣을 수 있는 구조는 결국 추가적인 코드 작업을 부른다. 하지만 딥러닝에서 많은 데이터는 축복과 같은것…
  3. 데이터를 버려도 될 정도로 많으니 overfitting 방어는 거의 필요 없게 되네..
  4. 성능 추이에 대한 모니터링은 텔레그램 봇을 통해서 받자. 나중에 잡 kill 명령도 텔레그램 봇으로?
  5. CuDNNGRUmulti_gpu_model이 아직 정식 릴리즈 되지 않았지만 시행착오를 빨리하는데 큰 도움이 되었다.

API의 호출방식은 달라진게 없고, 이 함수패키지를 활용하면 쉽게 가능하다. Python에서 호출하고 싶다면 필자의 포스트를 참고하면 된다.


그동안 이 API를 둘러싸고 많은 흥미로운 일이 있었다.
일단 이 API 호출을 하게 되면 서버에서 로그를 남기게 해두었다. 입력과 출력에 대한 로그이며, 이 로그는 사용자의 요구사항을 엿들어 볼 수 있는 일종의 창구 역할을 하고 있는데, 상당수의 로그가 영화리뷰에 대한 로그였다. 아무래도 네이버 영화 리뷰 분석을 많이들 하시니 그 영향이 아닌가 싶기도 하고…

흥미롭게도 대부분의 리뷰는 띄어쓰기를 별도 하지 않아도 될 리뷰였으나, 몇몇 리뷰의 경우 아래와 같이 필요한 리뷰였다.

sent : 그만해라 야붕이들아ㅠ
corrected : 그만해라 야 붕이 들 아ㅠ

sent : 이게영화냐이게영화냐이게영화냐
corrected : 이게 영화냐이게 영화냐이게 영화냐

위에 보다시피 잘 정제되지 않은 인터넷 은어와 구어체 표현에 엔진이 취약한 것을 알 수 있었다. 물론 이 부분은 당연한 부분이다. 왜냐하면 딥 뉴럴넷 모형은 위와 같은 학습셋을 본적이 없기 때문이다. 따라서 인터넷 용어 및 구어체 표현에 대한 띄어쓰기 성능은 앞으로 개선이 되어야 될 부분이라 생각하는데, 이를 위해서 양질의 학습셋을 어떻게 구하는가 하는 문제가 남아 있다. 사용자가 이러한 전처리를 하는게 뒷단에 형태소 분석기를 구동시키기 위함인데, 올바르게 띄어쓰기를 해주더라도 형태소 분석기가 저러한 문장을 잘 처리할지도 걱정이긴 하다.

필자가 구한 구어체 표현들로 평가해본 결과 문어체에 비해서 약 1%정도의 성능 저하가 나타나는걸 볼 수 있었는데, 아마도 체감으로는 더 클거라 생각한다. 왜냐하면 상대적으로 인터넷에 존재하는 구어체에는 신조어/은어들이 예상보다 많이 등장하기 때문이다.

API 공개 후 이틀만에 이 API를 이용한 kospacing 패키지가 등장했다. 물론 이 패키지 이전에 이미 함수를 만들어 공개한 분도 있었다. 그만큼 자동화된 한글 띄어쓰기 패키지에 대한 열망이 있었다고 생각이 들며, 이번 API 공개를 통해 더 훌륭한 공개 패키지들이 많이 등장했으면 하는 바램이 있다.

초반 구글 클라우드에서 Flask 기반으로 REST API 서버를 구성했는데(python 웹서버 기반), 잦은 서버 다운으로 몇일 고생을 좀 하다가, nginx, uwsgi, flask 조합으로 아무런 문제없이 운영하고 있다. 하지만 클라우드 서버 인스턴스를 가장 저렴한걸로 해놔서 많은 트래픽이 유입될 시 문제가 될 소지는 있다.

이 띄어쓰기 엔진은 keras + tensorflow 기반으로 학습되었다. 이를 R 패키지로 만드는건 아직은 다소 어려운 이슈이다. R 패키지로 만들기 위해서는 keras, tensorflow, Python 등 수많은 필요 외래 패키지가 필요한데, 이런 구조로 공식적인 패키지로 등록이 거의 불가능하기 때문이다. 그래서 찾아보고 있는게 Cpp로 모델을 포팅하는 모듈인데, 아직 쓸만한 모듈을 찾지 못한 상황이다. Model as a Program이 일반화 되기에는 시기상조이지 않을까 하는 생각이 먼저든다.

장기적인 계획은 이 모형을 R, Python 패키지 내에 포함시키는 적절한 방식을 찾아내 REST API가 아닌 stand-alone으로 동작하게 하는것이다. 하지만 그 방식을 빨리 찾을 수 있을것 같지는 않아 보인다.

맥주마시며 만들어본 딥러닝 맥주 추천엔진

퇴근 후 간단한 저녁과 함께 데낄라 한잔을 하고 집에 와서 맥주를 마시다가 4년전에 킵해둔 맥주 리뷰 데이터 생각이 나서 이 데이터 기반으로 10분만에 딥러닝 맥주 추천엔진을 만들어 봤다. 학습하는데 10분이 더 걸렸던 것을 빼놓고는 생각보다 나쁘지 않은 엔진이 구축되었다.

딥러닝 기반의 추천엔진 학습은 사용자 임베딩 매트릭스와 맥주 임베딩 매트릭스의 가중치를 학습하는게 목적이며 이 임베딩 매트릭스 기반으로 추천이 동작하게 된다. 물론 네트워크 자체를 활용할 수도 있고 유저 임베딩을 별도로 활용하는 방식도 있을 것이나, 이 부분은 서비스 컨셉에 따라서 다를 것이니 일단 넘어가자!

학습은 각 사용자 임베딩과 맥주 임베딩이 주어졌을 때 이들간의 벡터 내적이 등급에 가깝게 가중치 학습이 되게 하는 과정으로 학습이 진행된다. 이를 개념적으로 설명하자면 어느 두 고객이 기네스 맥주에 대해서 같은 등급을 매겼다고 가정 한다면,  기네스 맥주 가중치와 각각 고객의 가중치의 내적이 유사한 값이 도출될 수 있도록 상호 가중치 조정이 되게 된다.  결과적으로 기네스 맥주를 사이에 두고 두 고객 가중치는 유사한 가중치를 가지게끔 조정이 되게 될 것이다. 비슷한 데이터로 계속 학습될 경우 결국 두 고객은 매우 유사한 가중치(벡터)를 가지게 될 것이고 유사 고객으로 판단될 수 있을 것이다. 이와 반대로 한 고객이 두 맥주에 유사한 등급을 매겼다면 두 맥주는 비슷한 맥주로 가중치가 점점 조정될 것이다.

 

In [8]:
import pandas as pd
from keras.layers import *
from keras.regularizers import *
from keras.models import *
from sklearn.metrics.pairwise import euclidean_distances
%matplotlib inline
In [9]:
beers = pd.read_csv('beer_reviews.csv.bz2')
idx_reviewer = beers.groupby('review_profilename').size().reset_index()
idx_reviewer.shape
Out[9]:
(33387, 2)
In [10]:
user2idx = dict(zip(idx_reviewer.review_profilename, idx_reviewer.index.values))
idx_beers = beers.groupby('beer_name').size().reset_index()
idx_beers.shape
Out[10]:
(56857, 2)
In [11]:
beer2idx = dict(zip(idx_beers.beer_name, idx_beers.index.values))
idx2beer = dict(zip(idx_beers.index.values,idx_beers.beer_name))

beers['beer_idx'] = [beer2idx.get(b) for b in beers.beer_name]
beers['user_idx'] = [user2idx.get(u) for u in beers.review_profilename]

beers.loc[:, ['user_idx', 'beer_idx', 'review_overall']].as_matrix()

n_users = len(user2idx) 
n_beers = len(beer2idx) 
In [4]:
user_in = Input(shape=(1,), dtype='int64', name='user_in')
u = Embedding(n_users, 20, input_length=1,embeddings_regularizer=l2(1e-5))(user_in)

beer_in = Input(shape=(1,), dtype='int64', name='beer_in')
b = Embedding(n_beers, 20, input_length=1,embeddings_regularizer=l2(1e-5))(beer_in)

x = Dot(axes=2)([u,b])
x = Flatten()(x)

model = Model([user_in, beer_in], x)

model.compile('Adam',loss='mse')

model.summary()
__________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                     
==================================================================================================
user_in (InputLayer)            (None, 1)            0                                            
__________________________________________________________________________________________________
beer_in (InputLayer)            (None, 1)            0                                            
__________________________________________________________________________________________________
embedding_1 (Embedding)         (None, 1, 20)        667740      user_in[0][0]                    
__________________________________________________________________________________________________
embedding_2 (Embedding)         (None, 1, 20)        1137140     beer_in[0][0]                    
__________________________________________________________________________________________________
dot_1 (Dot)                     (None, 1, 1)         0           embedding_1[0][0]                
                                                                 embedding_2[0][0]                
__________________________________________________________________________________________________
flatten_1 (Flatten)             (None, 1)            0           dot_1[0][0]                      
==================================================================================================
Total params: 1,804,880
Trainable params: 1,804,880
Non-trainable params: 0
__________________________________________________________________________________________________
In [19]:
from IPython.display import Image
from keras.utils.vis_utils import model_to_dot

Image(model_to_dot(model,show_shapes=True, show_layer_names=False).create(prog='dot', format='png'))
Out[19]:
In [6]:
data_set_mat = beers.loc[:, ['user_idx', 'beer_idx', 'review_overall']].as_matrix()

history = model.fit(x=[data_set_mat[:,0], data_set_mat[:,1]], y=data_set_mat[:,2], 
                    batch_size=64, epochs=10, validation_split=0.1, verbose=2)
Train on 1427952 samples, validate on 158662 samples
Epoch 1/10
 - 66s - loss: 4.6260 - val_loss: 15.3516
Epoch 2/10
 - 60s - loss: 2.5994 - val_loss: 15.3719
Epoch 3/10
 - 60s - loss: 2.5567 - val_loss: 15.3726
Epoch 4/10
 - 60s - loss: 2.5165 - val_loss: 15.3920
Epoch 5/10
 - 60s - loss: 2.4975 - val_loss: 15.3773
Epoch 6/10
 - 60s - loss: 2.4882 - val_loss: 15.3994
Epoch 7/10
 - 60s - loss: 2.4841 - val_loss: 15.3882
Epoch 8/10
 - 60s - loss: 2.4778 - val_loss: 15.3692
Epoch 9/10
 - 60s - loss: 2.4743 - val_loss: 15.3741
Epoch 10/10
 - 60s - loss: 2.4737 - val_loss: 15.3721
In [12]:
beer_dist = euclidean_distances(X=model.layers[3].get_weights()[0])

#[k for k, v in beer2idx.items() if k.startswith('Guinness')]
In [13]:
def get_beer_recomm(beer_nm, beer_dist, idx2beer, topN=10):
    q_b_idx = beer2idx[beer_nm]
    beer_dists = beer_dist[q_b_idx]
    orders = np.argsort(beer_dists)
    return(zip(beer_dists[orders[:topN]], [idx2beer[i] for i in orders[:topN]]))
    
    
In [15]:
rec = get_beer_recomm('Indica India Pale Ale',beer_dist, idx2beer, topN=10)
tuple(rec)
Out[15]:
((0.0, 'Indica India Pale Ale'),
 (0.18242778, 'Bengali Tiger'),
 (0.18408915, 'Tröegs Dead Reckoning Porter'),
 (0.20235237, 'ACME California IPA'),
 (0.20946412, 'Odell 90 Shilling Ale'),
 (0.21144439, 'Kentucky Bourbon Barrel Ale'),
 (0.21554285, 'Juniper Pale Ale'),
 (0.22981298, 'Bully! Porter'),
 (0.23215295, 'Full Sail Wassail'),
 (0.23331977, 'Terrapin Hopsecutioner'))
In [16]:
rec = get_beer_recomm('Samuel Adams Boston Lager',beer_dist, idx2beer, topN=10)
tuple(rec)
Out[16]:
((0.0, 'Samuel Adams Boston Lager'),
 (0.44417816, 'Anchor Steam Beer'),
 (0.47144812, 'Samuel Adams Octoberfest'),
 (0.49871373, 'Hoegaarden Original White Ale'),
 (0.50791579, 'Samuel Adams Noble Pils'),
 (0.53693092, "Bell's Oberon Ale"),
 (0.54775667, 'Samuel Adams Winter Lager'),
 (0.58550274, 'Guinness Extra Stout (Original)'),
 (0.60181528, 'Franziskaner Hefe-Weisse'),
 (0.61106598, "Samuel Smith's Nut Brown Ale"))

 


인디카 맥주의 경우 추천을 해주는 맥주들이 대부분 IPA나 ALE류 인것을 알 수 있고(참고로 Bengali Tiger는 IPA다), 세뮤엘 아담스 라거의 경우 같은 양조장의 세뮤엘 아담스 류의 맥주와 필스너, 라거, White Ale, Brown Ale 등 의 IPA보다 다소 도수가 낮고 맛이 강하지 않은 맥주가 추천되는 것을 알 수 있다.  뭐 추천이 되는 맥주를 맛볼 수 있다면 그러고 싶은데, 아쉽게도 대부분 국내에서 잘 팔지 않는 것들이다.

 

위 딥러닝 네트웍은 추천을 위한 매우 기본적인 구조를 가지고 있다. 실제 학습 데이터를 확인해 보면 맥주의 카테고리라든지 양조장 등의 정보가 있다.  이들 정보를 위 네트웍에 녹여넣어서 구현을 해본다면 좀더 나은 구현체가 나올 수 있을 것이고, 맥주와 고객의 임베딩을 pre-train하여서 그 결과를 가지고 학습을 해도 좋은 결과를 볼 수 있을 것이다.

위와 같은 dense하고 deep한 임베딩을 기반으로 학습하는 경우의 가장 큰 장점은 매우 일반화된 추천을 할 수 있다는 것이라 할 수 있을 것이다.  이 덕분에 어떠한 맥주를 넣더라도 그럴싸한 맥주를 추천해줄 수 있으나, 정확도는 떨어질 가능성이 있다.  이를 위해 실제 평점 데이터를 Exploit 할 수 있는 추가적인 네트웍(wide한)을 구성하는게 최근 추세인데, 결과적으로 두 네트워간의 밸런스를 어떻게 하느냐에 따라서 추천의 성격이 달라질 수 있다.  Exploit,  Explore 둘중에 어떤것에 중점을 둘 것인가?   이 부분이 추천에서의 아트영역이지 않을까 한다.

언제나 처럼 코드와 데이터는 필자의 Git에 올려두었고, 혹시나 고전적인 방식의 추천이 궁금하다면 필자가 4년 전에 작성한 포스트를 참고해도 좋을 것이다.