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

퇴근 후 간단한 저녁과 함께 데낄라 한잔을 하고 집에 와서 맥주를 마시다가 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년 전에 작성한 포스트를 참고해도 좋을 것이다.

 

딥러닝 기반 한글 자동 띄어쓰기 API 공개

형태소 분석 이전에 문자열의 정상 유무는 이후 분석 품질에 지대한 영향을 미친다. 최근 음성인식 정확도가 높아짐에 따라 많은 음성데이터가 Text로 변환되고 분석되기 시작했는데, 이를 위해 잘 동작하는 띄어쓰기 엔진은 텍스트 분석에서 거의 필수적인게 되어 버렸다. 또한 트위터나 페이스북의 사용자 생성 데이터를 분석하기 위해서 일종의 정규화 작업이 필수이며 양질의 띄어쓰기 엔진 확보가 점차 중요해지고 있다.

개인적으로 이런 저런 시도를 해오면서 큰 진전이 없다가 작년부터 어떻게 하면 딥러닝으로 한글 자동 띄어쓰기를 잘 해볼까 라는 고민을 하기 시작했고 이번 추석 연휴를 기회로 쓸만한 띄어쓰기 엔진이 만들어져 간단한 API로 공개해본다. 세종 코퍼스 1만 문장 기준 94.8% 그리고 최근 뉴스 기사 기준 95.3%의 띄어쓰기 정확도를 가지는 엔진으로 박찬엽씨가 수집/공유해준 2017년도 뉴스 기사 중 1억 문장으로 학습되었다.

딥러닝 기반의 첫 시도는 KoWordSpacing Git에 공개되어 있으나 현재 이 방식하고는 상당부분 달라졌고, 그렇게 개선된 딥러닝 아키텍처 덕분에 성능향상이 두드러졌다고 판단하고 있다. 관련 아키텍처는 별도 기회(논문이나 기고)를 통해 공유를 할 생각이다.

아래와 같은 방식으로 API를 사용할 수 있으며, 호출 건수에 대해서 별도의 제한은 없으나, 1회 호출에 200글자로 글자수를 제한하고 있어 문장단위로 입력을 하면 띄어쓰기가 된 문장을 리턴받을 수 있다.

Python기반 (>= 3.3)

from requests import put

spaced_sent = put('http://35.201.156.140:8080/spacing', 
      data={'sent':"최근음성인식정확도가높아짐에따라많은음성데이터가Text로변환되고분석되기시작했는데,이를위해잘동작하는띄어쓰기엔진은거의필수적인게되어버렸다."}).json() 

print(spaced_sent['sent'])

## 최근 음성인식 정확도가 높아짐에 따라 많은 음성데이터가 Text로 변환되고 분석되기 시작했는데, 이를 위해 잘 동작하는 띄어쓰기엔진은 거의 필수적인 게 되어버렸다.

R기반

library(reticulate)

requests <- import('requests')

spaced_sent <- requests$put('http://35.201.156.140:8080/spacing', 
      data=list(sent="아래와같은방식으로API를사용할수있으며,호출건수에대해서별도의제한은없으나,1회 호출에200글자로글자수를제한하고있다."))$json() 


print(spaced_sent$sent)
## [1] "아래와 같은 방식으로 API를 사용할 수 있으며, 호출 건수에 대해서 별도의 제한은 없으나, 1회 호출에 200글자로 글자수를 제한하고 있다. "

R’s way for Deep Learning with Keras

Keras는 high level 딥러닝 API의 표준을 달리고 있는 딥러닝 프레임웍 중에 하나이다. TensorFlow를 기점으로 Theano, CNTK를 지원하고 있으며, 현재 MXNet까지 관련 인터페이스를 개발하고 있어 점점 딥러닝의 표준으로 자리잡고 있다.

필자는 Keras(or TensorFlow) + Python 기반으로 실무를 하고 있는데, 사실 딥러닝 프레임웍을 제외하고는 데이터를 다루는 모든면에서 R이 더 효과적이라고 생각하고 있는 사람중에 하나이고 많은 분들이 이 부분에 대해서 공감하고 있다. 일단 시각화도 그렇고 data.frame의 대응인 pandas의 거의 재앙에 가까운 API 구조에 질려본 사용자들은 이 부분에 대해서 공감할 것이다. 물론 pandas + numpy 조합으로 그나마 데이터 기반 코딩하는 재미를 살려볼 여지는 충분히 있어서 그나마 다행이라 생각하고 있다.
개인적으로 생각하는 데이터 분석에서의 Python의 강점은 풍부한 언어적인 특징 덕분에 코드의 성능향상 엔지니어링을 발휘할 수 있다는 것과 코드가 깔끔하게 정리된다는 장점 등 많은 장점이 존재한다고 생각한다.

일단 필자는 Python과 R 모두 사용하기로 했고 나름의 언어 사용의 재미 또한 느끼고 있다.

왜 필자가 Python과 R 이야기를 이 시점에서 하는냐면 약 1달전 R 기반의 Keras 패키지를 사용하고 상당히 감명을 받아서 이런 저런 테스트를 하다가 Keras github에 이러한 이슈를 남기고 사용하기를 중지했기 때문이다. 물론 시도를 해보긴 했지만 yield구문을 통한 python generator 구현은 R에서 거의 불가능하다고 생각했기 때문이다. 일단 이 기능은 메모리에 모두 올라가지 않는 데이터에 대한 학습 예측을 위한 기능을 구현할 수 있는 언어적인 기법이었는데, Python에서는 내부적으로 thread로 구현되어 있기 때문이었다. 해당 문법도 없고, 언어적으로 thread 기능을 지원하지 않는 관계로 R에서 불가능하다고 생각했다. 이 부분은 ggplot2 개발자인 헤들리 위컴도 동의한 부분이었다.

실제 fit_generator()는 실무를 한다면 반드시 활용해야 되는 중요한 학습 함수이다. 위에 이야기한 큰 데이터를 학습하기 위한 목적 이외에 데이터의 클래스 벨런스 및 셔플링 등등 학습에 반드시 필요한 작업들을 수행할 수 있는 유일한 부분이기 때문이었다.

일단 이러한 중요 문제 때문에 필자도 덮어두고 아예 Python으로 데이터 전처리 및 모델링을 하고 있었는데, 아래와 같은 commit이 올라와 확인해보니 관련 기능이 구현된 것을 확인하고 깜짝 놀라기까지 했다. 게다가 관련 문서까지 업데이트 된 것을 확인했다.
물론 이 기능은 py_iterator()를 구현하고 있는 reticulate패키지의 공이긴 하다.

일단 필자는 R에서 구현된 Keras가 얼마나 깔끔한 문법 구조와 더불어 사용자 편의성을 추구하고 있으며 강점인 시각화를 어떻게 활용하는지 간단한 예제를 통해 보여주고자 한다. 아래 예제는 이 코드를 기반으로 추가 코드를 작성했다는 걸 밝힌다.

library(keras)


batch_size <- 128
num_classes <- 10
epochs <- 10

# MNIST 데이터 가져오기 
mnist <- dataset_mnist()
x_train <- mnist$train$x
y_train <- mnist$train$y
x_test <- mnist$test$x
y_test <- mnist$test$y

x_train <- array(as.numeric(x_train), dim = c(dim(x_train)[1], 784))
x_test <- array(as.numeric(x_test), dim = c(dim(x_test)[1], 784))

x_train <- x_train / 255
x_test <- x_test / 255

cat(dim(x_train)[1], 'train samples\n')
## 60000 train samples
cat(dim(x_test)[1], 'test samples\n')
## 10000 test samples
# 더미변수로 변환 
y_train <- to_categorical(y_train, num_classes)
y_test <- to_categorical(y_test, num_classes)


#기존의 model.add 를 Pipe 연산자로 구성해 더 깔끔해졌다.
#이런게 바로 R's way...  
model <- keras_model_sequential()
model %>% 
  layer_dense(units = 256, activation = 'relu', input_shape = c(784)) %>% 
  layer_dropout(rate = 0.4) %>% 
  layer_dense(units = 128, activation = 'relu') %>%
  layer_dropout(rate = 0.3) %>%
  layer_dense(units = 10, activation = 'softmax')

summary(model)
## Model
## ___________________________________________________________________________
## Layer (type)                     Output Shape                  Param #     
## ===========================================================================
## dense_1 (Dense)                  (None, 256)                   200960      
## ___________________________________________________________________________
## dropout_1 (Dropout)              (None, 256)                   0           
## ___________________________________________________________________________
## dense_2 (Dense)                  (None, 128)                   32896       
## ___________________________________________________________________________
## dropout_2 (Dropout)              (None, 128)                   0           
## ___________________________________________________________________________
## dense_3 (Dense)                  (None, 10)                    1290        
## ===========================================================================
## Total params: 235,146
## Trainable params: 235,146
## Non-trainable params: 0
## ___________________________________________________________________________
## 
## 
model %>% compile(
  loss = 'categorical_crossentropy',
  optimizer = optimizer_rmsprop(),
  metrics = c('accuracy')
)

history <- model %>% fit(
  x_train, y_train,
  batch_size = batch_size,
  epochs = epochs,
  verbose = 1,
  callbacks = callback_tensorboard(log_dir = "logs/run_b"),
  validation_split = 0.2
)

Train on 48000 samples, validate on 12000 samples
Epoch 1/10
48000/48000 [==============================] - 0s - loss: 0.5470 - acc: 0.8288 - val_loss: 0.1896 - val_acc: 0.9437
Epoch 2/10
48000/48000 [==============================] - 0s - loss: 0.2630 - acc: 0.9246 - val_loss: 0.1368 - val_acc: 0.9606
Epoch 3/10
48000/48000 [==============================] - 0s - loss: 0.2080 - acc: 0.9407 - val_loss: 0.1271 - val_acc: 0.9635
Epoch 4/10
48000/48000 [==============================] - 0s - loss: 0.1773 - acc: 0.9486 - val_loss: 0.1188 - val_acc: 0.9679
Epoch 5/10
48000/48000 [==============================] - 0s - loss: 0.1660 - acc: 0.9532 - val_loss: 0.1115 - val_acc: 0.9716
Epoch 6/10
48000/48000 [==============================] - 0s - loss: 0.1506 - acc: 0.9578 - val_loss: 0.1065 - val_acc: 0.9732
Epoch 7/10
48000/48000 [==============================] - 0s - loss: 0.1458 - acc: 0.9595 - val_loss: 0.1058 - val_acc: 0.9733
Epoch 8/10
48000/48000 [==============================] - 0s - loss: 0.1415 - acc: 0.9628 - val_loss: 0.1090 - val_acc: 0.9735
Epoch 9/10
48000/48000 [==============================] - 0s - loss: 0.1333 - acc: 0.9644 - val_loss: 0.1114 - val_acc: 0.9735
Epoch 10/10
#아래 텐서보드 명령어로 R shell에서 바로 web browser를 열어 텐서보드를 띄울 수 있다.  
#tensorboard()

#아래 명령어로 매트릭에 대한 에폭별 성능을 바로 시각화 한다. 
plot(history)

plot of chunk unnamed-chunk-1

아래는 간단히 fit_generator()를 사용하는 예시를 보여준다.

#아래 코드는 학습셋 레코드를 300여개의 배치 그룹으로 리셈플링 기법으로 구성한 예시이다. 
seq.gen <- function(x_train, y_train, v=0){
  value <- v
  idx <- sample(1:300, dim(x_train)[1], replace = T)
  function() {
    value <<- value + 1
    list(x_train[which(idx == (value %% 300 + 1)), ], y_train[which(idx == (value %% 300 + 1)), ])
  }
}


history2 <- model %>% fit_generator(generator=seq.gen(x_train, y_train, 0), steps_per_epoch=300, epochs = 5)
Epoch 1/5
300/300 [==============================] - 2s - loss: 0.1333 - acc: 0.9658 
Epoch 2/5
300/300 [==============================] - 2s - loss: 0.1207 - acc: 0.9679 
Epoch 3/5
300/300 [==============================] - 2s - loss: 0.1169 - acc: 0.9697 
Epoch 4/5
300/300 [==============================] - 2s - loss: 0.1114 - acc: 0.9707

사실 위 generator는 Python의 그것과는 다른 존재이다. 왜냐면 Python의 generator를 흉내만 낸 실상 iterator이기 때문이다. 이 때문에 헤들리 위컴 등 개발자들이 R용 yield문 등 generator를 구현하는 걸 고민하고 있다. 그렇다고 R의 그것이 성능이 떨어지는 건 절대 아닌데, 왜냐면 Python의 스레드도 GIL(global interpreter lock) 때문에 반쪽짜리 쓰레드여서 거의 하나의 CPU를 사용하는 것과 유사한 효과를 발휘하기 때문이다. ㅎㅎ

여튼 업무에는 Python 기반, 개인적인 연구엔 R기반의 Keras를 사용할 예정이다. 무엇보다 데이터 핸들링/시각화 하기가 R이 편한 이유 하나 때문이다. ㅋ