퇴근 후 간단한 저녁과 함께 데낄라 한잔을 하고 집에 와서 맥주를 마시다가 4년전에 킵해둔 맥주 리뷰 데이터 생각이 나서 이 데이터 기반으로 10분만에 딥러닝 맥주 추천엔진을 만들어 봤다. 학습하는데 10분이 더 걸렸던 것을 빼놓고는 생각보다 나쁘지 않은 엔진이 구축되었다.
딥러닝 기반의 추천엔진 학습은 사용자 임베딩 매트릭스와 맥주 임베딩 매트릭스의 가중치를 학습하는게 목적이며 이 임베딩 매트릭스 기반으로 추천이 동작하게 된다. 물론 네트워크 자체를 활용할 수도 있고 유저 임베딩을 별도로 활용하는 방식도 있을 것이나, 이 부분은 서비스 컨셉에 따라서 다를 것이니 일단 넘어가자!
학습은 각 사용자 임베딩과 맥주 임베딩이 주어졌을 때 이들간의 벡터 내적이 등급에 가깝게 가중치 학습이 되게 하는 과정으로 학습이 진행된다. 이를 개념적으로 설명하자면 어느 두 고객이 기네스 맥주에 대해서 같은 등급을 매겼다고 가정 한다면, 기네스 맥주 가중치와 각각 고객의 가중치의 내적이 유사한 값이 도출될 수 있도록 상호 가중치 조정이 되게 된다. 결과적으로 기네스 맥주를 사이에 두고 두 고객 가중치는 유사한 가중치를 가지게끔 조정이 되게 될 것이다. 비슷한 데이터로 계속 학습될 경우 결국 두 고객은 매우 유사한 가중치(벡터)를 가지게 될 것이고 유사 고객으로 판단될 수 있을 것이다. 이와 반대로 한 고객이 두 맥주에 유사한 등급을 매겼다면 두 맥주는 비슷한 맥주로 가중치가 점점 조정될 것이다.
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
beers = pd.read_csv('beer_reviews.csv.bz2')
idx_reviewer = beers.groupby('review_profilename').size().reset_index()
idx_reviewer.shape
user2idx = dict(zip(idx_reviewer.review_profilename, idx_reviewer.index.values))
idx_beers = beers.groupby('beer_name').size().reset_index()
idx_beers.shape
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)
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()
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'))
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)
beer_dist = euclidean_distances(X=model.layers[3].get_weights()[0])
#[k for k, v in beer2idx.items() if k.startswith('Guinness')]
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]]))
rec = get_beer_recomm('Indica India Pale Ale',beer_dist, idx2beer, topN=10)
tuple(rec)
rec = get_beer_recomm('Samuel Adams Boston Lager',beer_dist, idx2beer, topN=10)
tuple(rec)
인디카 맥주의 경우 추천을 해주는 맥주들이 대부분 IPA나 ALE류 인것을 알 수 있고(참고로 Bengali Tiger는 IPA다), 세뮤엘 아담스 라거의 경우 같은 양조장의 세뮤엘 아담스 류의 맥주와 필스너, 라거, White Ale, Brown Ale 등 의 IPA보다 다소 도수가 낮고 맛이 강하지 않은 맥주가 추천되는 것을 알 수 있다. 뭐 추천이 되는 맥주를 맛볼 수 있다면 그러고 싶은데, 아쉽게도 대부분 국내에서 잘 팔지 않는 것들이다.
위 딥러닝 네트웍은 추천을 위한 매우 기본적인 구조를 가지고 있다. 실제 학습 데이터를 확인해 보면 맥주의 카테고리라든지 양조장 등의 정보가 있다. 이들 정보를 위 네트웍에 녹여넣어서 구현을 해본다면 좀더 나은 구현체가 나올 수 있을 것이고, 맥주와 고객의 임베딩을 pre-train하여서 그 결과를 가지고 학습을 해도 좋은 결과를 볼 수 있을 것이다.
위와 같은 dense하고 deep한 임베딩을 기반으로 학습하는 경우의 가장 큰 장점은 매우 일반화된 추천을 할 수 있다는 것이라 할 수 있을 것이다. 이 덕분에 어떠한 맥주를 넣더라도 그럴싸한 맥주를 추천해줄 수 있으나, 정확도는 떨어질 가능성이 있다. 이를 위해 실제 평점 데이터를 Exploit 할 수 있는 추가적인 네트웍(wide한)을 구성하는게 최근 추세인데, 결과적으로 두 네트워간의 밸런스를 어떻게 하느냐에 따라서 추천의 성격이 달라질 수 있다. Exploit, Explore 둘중에 어떤것에 중점을 둘 것인가? 이 부분이 추천에서의 아트영역이지 않을까 한다.
언제나 처럼 코드와 데이터는 필자의 Git에 올려두었고, 혹시나 고전적인 방식의 추천이 궁금하다면 필자가 4년 전에 작성한 포스트를 참고해도 좋을 것이다.