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

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

 

CC BY-NC 4.0 맥주마시며 만들어본 딥러닝 맥주 추천엔진 by from __future__ import dream is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.