02. 컨텐츠 기반 추천시스템 - Word2Vec을 이용한 추천시스템

해당 글은 T-아카데미에서 발표한 추천시스템 - 입문하기의 자료에 딥러닝을 이용한 추천시스템과 추천시스템 대회를 분석한 내용을 추가한 글입니다. 해당 자료보다 더욱더 좋은 자료들이 페이스북 그룹 Recommender System KR에 있으니 많은 관심 부탁합니다.

컨텐츠 기반의 추천시스템 - Word2Vec

이전에 텍스트 형태의 Items을 벡터 형태로 표현하는 방법으로 TF-IDF를 봤습니다. TF-IDF를 간단하게 요약하면 특정 문서 내에 특정 단어가 얼마나 자주 등장하는 지를 의미하는 단어 빈도(TF)와 전체 문서에서 특정 단어가 얼마나 자주 등장하는지를 의미하는 역문서 빈도(DF)를 통해서 “다른 문서에서는 등장하지 않지만 특정 문서에서만 자주 등장하는 단어"를 찾아서 문서 내 단어의 가중치를 계산하는 방법입니다.

  • TF : 특정 문서 d에서의 특정 단어 t의 등장 횟수
  • DF : 특정 단어 t가 등장한 문서의 수
  • IDF : DF에 반비례하는 수 (idf-smoothing 여부에 따라서 결과가 달라짐)
  • TF-IDF : TF와 IDF를 곱해준 값

 

하지만, 이런 통계기반의 방법에는 아래와 같은 단점들이 존재합니다. 단어를 모두 사용하다 보니 메모리상에서 큰 문제가 발생합니다.

  1. 대규모 말뭉치를 다룰 때 메모리 상의 문제가 발생

    • 높은 차원을 가짐 , 매우 sparse한 형태의 데이터임
    • 예) 100만개의 문서를 다루는 경우 : 100만개의 문서에 등장한 모든 단어를 추출해야하고 이때 단어의 수는 1문 서당 새로운 단어가 10개면, 1000만 개 정도의 말뭉치가 형성됨. 즉, 100만 x 1000만의 매트릭스가 형성
  2. 한 번에 학습 데이터 전체를 진행함

    • 큰 작업을 처리하기 어려움

    • GPU와 같은 병렬 처리를 기대하기 힘듦

  3. 학습을 통해서 개선하기가 어려움

추론 기반의 방법 (Word2Vec)

  • CBOW는 주변에 있는 단어들을 가지고, 중간에 있는 단어들을 예측하는 방법입니다. 반대로, Skip-Gram은 중간에 있는 단어로 주변 단어들을 예측하는 방법입니다.
    • CBOW : 주변 단어(맥락)를 통해서 중심 단어를 채우는 방법
    • Skip-gram : 중심 단어를 통해서 주변 단어를 채우는 방법

이때, Word2Vec에서 사용되는 용어들에 대해서 한번 살펴보면 크게 3가지 용어를 사용합니다.

  • 주변 단어 : 주변에 있는 단어 (you, goodbye)
  • 중심 단어 : 중간에 있는 단어 (say)
  • 윈도우 크기 : 주변을 몇 칸까지 볼 지에 대한 크기 (1)

한번, 이제 두 모델의 알고리즘을 살펴보도록 하겠습니다.

Word2Vec (CBOW)

CBOW 모델의 기본적인 네트워크를 보면 아래와 같습니다. One-Hot 형태의 입력을 받아서 Weight와 곱해준 후에 Softmax를 취해주는 게 기본적인 딥러닝 구조와 똑같아 보입니다. (실제로 거의 똑같습니다.)

이제 이러한 네트워크를 한번 차례대로 뜯어보겠습니다. 먼저 데이터의 입력을 받습니다. 입력은 One-Hot Encoding 된 형태인데, 아래의 표에서 순차적으로 입력을 받아서 진행합니다.

 

위의 표에서 2번째 입력을 받은 게 아래의 그림입니다. You와 Goodbye라는 주변 단어를 통해서 say라는 중심 단어를 예측하는 형태가 됩니다. 이때, 주변 단어를 몇 개를 볼지가 윈도우의 크기입니다. 현재는 윈도우의 크기가 1이어서 양옆으로 1개의 단어를 봤지만, 윈도우의 크기가 2가 되면 say의 양옆 2개의 단어를 봐서 you, goodbye, and를 통해 say를 예측하게 됩니다.

 

이후에는 입력값과 W_in이라는 Weight를 곱해서 은닉층을 만들어줍니다. 이때, Weight는 랜덤한 값으로 초기화해서 사용해주면 됩니다. 추후에 다시 언급하겠지만, 여기서 발생하는 W_in이 단어들의 임베딩 값이 됩니다.

 

Hidden state의 값을 W_out과 곱해서 Score를 추출합니다. 이때, score는 print 된 값을 보면 알듯이 확률의 형태가 아닙니다. 이를 확률의 형태로 바꿔주는 좋은 테크닉인 Softmax를 취해줘서 확률의 형태로 바꿔줍니다.

 

이제 이 결과를 실제 정답과 한번 비교해보도록 하겠습니다. 이때, Label이 여러 개이기 때문에 Multi Label Classficiation에 사용하는 Cross Entropy Loss를 사용합니다.

 

이렇게 계산한 Loss를 가지고 Backpropagation 과정을 통해서 Weight를 업데이트해줍니다. Softmax의 Backpropagation은 Pi - yi으로 구성되는데 수식적으로 이게 나오는 이유는 아래의 블로그를 참고하시기 바랍니다.

 

weight의 역전파 값은 np.outer으로 계산할 수 있습니다. x에 대한 역전파 값은 np.dot으로 계산됩니다. 해당 내용은 다음의 블로그에서 참고하시기 바랍니다.

 

 

위의 과정을 통해서 우리는 입력값 x의 역전파 값(da)과 가중치 w의 역전파 값 dw_in, dw_out을 계산했습니다. weight 값이 you, goodbye처럼 정확히 입력값 부분에만 가지는 것을 확인할 수 있습니다.

이제 해당 값을 가지고 업데이트를 시작하겠습니다. 파라미터의 업데이트는 원래의 가중치에 학습률과 역전파 값을 곱한 것을 빼는 형식으로 계산이 됩니다. 여기서는 입력값이 2개이기에 2번 반복해서 계산을 진행해줍니다.

 

마찬가지로 W_out에 대해서도 동일한 과정을 진행해줍니다.

이제 위의 과정을 다음번의 문맥에 대해서도 차례대로 진행해줍니다. 이런 과정을 반복해서 진행하고 1번의 Epoch이 아닌 여러 Epoch에 대해서 학습하게 되면 W_in의 값이 각 단어의 의미를 가지는 Embedding값이 됩니다.

 

이제 CBOW 방식이 아닌 Skip-Gram 형식의 방법을 살펴보겠습니다. 실제 현업에서는 CBOW보다는 Skip-Gram 알고리즘을 많이 사용합니다. 그 이유는 일단 성능적인 측면이 큽니다. Skip-Gram은 CBOW 보다 좀 더 어려운 테스크를 수행하고, 하나의 문장에서 더 많인 데이터가 나옵니다.

  • you say goodbye and i say hello.
    • CBOW : 8개의 테스크
    • Skip-Gram : 8 * window size개의 테스크

Skip-Gram 같은 경우 위의 #2의 경우에서 중심 단어인 say를 통해 주변 단어 you와 goodbye를 전부 맞춰야 합니다. 그렇기에 하나의 입력에 정답은 2개가 되고 풀어야 하는 테스크는 2배가 되는 셈입니다. 한번, Skip-Gram의 알고리즘이 어떻게 진행되는지 살펴보겠습니다.

Skip-Gram의 기번적인 네트워크는 아래와 같습니다. CBOW와는 대칭적으로 입력층은 하나의 입력을 받고 출력층이 2개의 아웃풋을 뱉습니다.

 

입력값은 이전과 동일하게 One-Hot 인코딩 된 형태로 들어옵니다.

 

그리고 해당 입력값을 W_in과 곱해줌으로써 은닉층의 값을 만들어줍니다.

 

이후, CBOW와 마찬가지로 W_out과 곱해서 Score를 추출하고 Softmax를 취해서 확률로 변환해줍니다.

 

이제 Loss 값을 계산해주고 이를 역전파를 진행해줍니다.

역전파시에 오차는 더해준 값으로 진행해줍니다.

`np.outer`을 통해서 Weight에 대한 역전파 값을 계산해줍니다.

Weight에 대한 역전파 값(dw_in)은 `np.outer`을 통해서 계산하고 입력값 x에 대한 역전파 값(da)은 `np.dot`을 통해서 계산해줍니다.

이제 Learning Rate와 곱을 통해서 Weight에 대한 업데이트를 진행해줍니다.

 

마찬가지로 위의 과정을 다른 문맥에 대해서도 반복해서 수행해주면 Skip-Gram의 알고리즘이 끝나게 됩니다.

딥러닝을 어느 정도 아시는 분이라면 위의 내용이 대부분 쉽게 다가올 것입니다. 하지만, numpy로 코드를 작성하는 건 쉽지 않습니다. 그래서 보통은 gensim에서 제공해주는 Word2Vec 패키지를 사용합니다. 한번 이전 [02. TF-IDF를 이용한 추천시스템]에서 사용한 영화 평점 데이터에 Word2Vec을 이용한 추천시스템을 만들어보도록 하겠습니다.

실습

import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
import gensim 
import warnings
warnings.filterwarnings(action='ignore')
# 경로의 경우 각자의 환경에 맞게 설정해주면 됩니다. 
path = 'C:/Users/User/Desktop/추천시스템 입문하기/05. 추천시스템 실습하기/input/movies/'
movie = pd.read_csv(path + 'ratings.csv', low_memory=False)
movie.head(2)

# Timestamp 기준으로 정렬해줍니다. (ascending=True)를 통해서 시간순서대로 정렬됩니다. 
movie = movie.sort_values(by='timestamp', ascending=True).reset_index(drop=True)
movie.head()

# 영화의 Metadata를 불러와서 movieID에 맞는 TITLE을 구해줍니다. 
meta = pd.read_csv(path + 'movies_metadata.csv', low_memory=False)
meta.head(2)

# id의 컬럼명을 movie와 동일하게 바꿔줍니다. 
meta = meta.rename(columns={'id':'movieId'})
# index 형태의 타입은 모두 string으로 변경해줍니다. 
movie['movieId'] = movie['movieId'].astype(str)
meta['movieId'] = meta['movieId'].astype(str)

# movie와 meta 데이터를 결합해줍니다. 
movie = pd.merge(movie, meta[['movieId', 'original_title']], how='left', on='movieId')
# original_title에 결측치가 없는 항목만 사용해줍니다. 
movie = movie[movie['original_title'].notnull()].reset_index(drop=True)
# userId로 groupby해서 original_title의 unique 항목을 추출해줍니다. 
# 해당 코드를 통해서 사용자가 본 영화의 제목이 모두 저장됩니다. 
agg = movie.groupby(['userId'])['original_title'].agg({'unique'})
agg.head()

# int형식은 Word2vec에서 학습이 안되어서 String으로 변경해줍니다. 
sentence = []
for user_sentence in agg['unique'].values:
    sentence.append(list(map(str, user_sentence)))
# Word2vec의 학습을 진행해줍니다. 
from gensim.models import Word2Vec
# sg = 1은 Skip Gram을 사용하라는 명령어입니다. 
embedding_model = Word2Vec(sentence, size=20, window = 5, 
                           min_count=1, workers=4, iter=200, sg=1)
# Spider-Man 2와 유사한 영화목록을 추출합니다. 
embedding_model.wv.most_similar(positive=['Spider-Man 2'], topn=10)
[('Snow Cake', 0.8647751212120056),
 ('Sunrise: A Song of Two Humans', 0.7735385894775391),
 ('Face/Off', 0.764630913734436),
 ('Harry Potter and the Prisoner of Azkaban', 0.7302199602127075),
 ('Licence to Kill', 0.7103623747825623),
 ('The Godfather', 0.7101566791534424),
 ('薔薇の葬列', 0.7095184326171875),
 ('Domicile Conjugal', 0.7022347450256348),
 ('Rumor Has It...', 0.6998509168624878),
 ('Forrest Gump', 0.6897859573364258)]

생각보다 Word2Vec을 이용한 영화 추천은 결과가 안 좋았습니다. 아무래도 영화 간의 연속성이 떨어지다 보니 해당 문제가 발생한 것 같습니다.

이번에는 Doc2Vec을 이용해서 Overview를 가지고 유사한 Documentary를 찾아보도록 하겠습니다. Doc2Vec은 Word2Vec의 변형버전인데, 문서의 번호를 삽입해서 문서 간의 유사도도 계산할 수 있도록 만든 모델입니다.

from gensim.models import doc2vec
meta = pd.read_csv(path + 'movies_metadata.csv', low_memory=False)
meta = meta[meta['original_title'].notnull()].reset_index(drop=True)
meta = meta[meta['overview'].notnull()].reset_index(drop=True)
from nltk.corpus import stopwords 
from tqdm.notebook import tqdm
from nltk.tokenize import word_tokenize, sent_tokenize
# english 형태로 불용어를 뽑아줍니다. 
stop_words = set(stopwords.words('english')) 

overview = []
for words in tqdm(meta['overview']):
    # word_tokenize을 통해서 Tokenizer를 진행해줍니다. 
    word_tokens = word_tokenize(words)
    # re.sub('[^a-z0-9]+', ' ', str(word_tokens)) 명령어를 통해서 특수문자를 모두 제거해줍니다. 
    sentence = re.sub('[^A-Za-z0-9]+', ' ', str(word_tokens))
    # 양끝에 존재하는 공백을 제거해줍니다. 
    sentence = sentence.strip()

    # 문장의 Tokenizer를 진행해줍니다. 
    sentence_tokens = word_tokenize(sentence)
    result = ''
    for token in sentence_tokens: 
        if token not in stop_words:
            result += ' ' + token 
    result = result.strip().lower()
    overview.append(result)
meta['pre_overview'] = overview
doc_vectorizer = doc2vec.Doc2Vec(
    dm=0,            # PV-DBOW / default 1
    dbow_words=1,    # w2v simultaneous with DBOW d2v / default 0
    window=10,        # distance between the predicted word and context words
    size=100,        # vector size
    alpha=0.025,     # learning-rate
    seed=1234,
    min_count=5,    # ignore with freq lower
    min_alpha=0.025, # min learning-rate
    workers=4,   # multi cpu
    hs = 1,          # hierar chical softmax / default 0
    negative = 10   # negative sampling / default 5
)
from collections import namedtuple

# Doc2Vec 입력형태를 맞춰줍니다. 
# TaggedDocument 형태로 ('영화 내용', '제목') 을 받습니다. 
## 이후, TaggedDocument에 모든 문서에 대해서 '영화 내용', '제목'을 넣어줍니다. 
agg = meta[['original_title', 'pre_overview']]
TaggedDocument = namedtuple('TaggedDocument', 'words tags')
tagged_train_docs = [TaggedDocument((c), [d]) for d, c in agg[['original_title', 'pre_overview']].values]
# 위에서 만든 tagged_train_docs으로 사전을 만들어줍니다. 
doc_vectorizer.build_vocab(tagged_train_docs)
print(str(doc_vectorizer))
# 벡터 문서 학습
from time import time

start = time()

for epoch in tqdm(range(30)):
    # Doc2Vec 학습을 진행하는데 Learning rate를 계속 감소해주면서 학습을 진행해줍니다. 
    doc_vectorizer.train(tagged_train_docs, total_examples=doc_vectorizer.corpus_count, epochs=doc_vectorizer.iter)
    doc_vectorizer.alpha -= 0.002 # decrease the learning rate
    doc_vectorizer.min_alpha = doc_vectorizer.alpha # fix the learning rate, no decay

end = time()
print("During Time: {}".format(end-start))
# Toy Story와 가장 유사한 상위 20개의 영화를 추출합니다. 
doc_vectorizer.docvecs.most_similar('Toy Story', topn=20)
[('It Stains the Sands Red', 0.7155202627182007),
 ('Spark: A Space Tail', 0.7068586945533752),
 ('Letzte Worte', 0.7039177417755127),
 ('El vendedor de humo', 0.67961585521698),
 ('Skazka o Poteryannom Vremeni', 0.6796030402183533),
 ('エクスマキナ', 0.6743360161781311),
 ('Milk Money', 0.6733628511428833),
 ('La moutarde me monte au nez', 0.6681728363037109),
 ('Children in the Surf at Coney Island', 0.662685751914978),
 ('Kader', 0.6612146496772766),
 ("Independents' Day", 0.6611530780792236),
 ('Meet Me in Venice', 0.6607648134231567),
 ('My Friends Need Killing', 0.6578397154808044),
 ('Live Forever as You Are Now with Alan Resnick', 0.6542983055114746),
 ('Burning Sands', 0.6528704166412354),
 ('Особенности национальной политики', 0.650513768196106),
 ('The Aristocats', 0.6483055949211121),
 ('Begegnung mit Fritz Lang', 0.6453357934951782),
 ('8 Pervykh Svidaniy', 0.644992470741272),
 ('Der Sandmann', 0.6428802013397217)]
doc_vectorizer.docvecs.most_similar('Harry Potter and the Deathly Hallows: Part 1', topn=20)
[('Never Let Me Go', 0.7440825700759888),
 ('Cold Weather', 0.7169094085693359),
 ('Who Is Harry Kellerman and Why Is He Saying Those Terrible Things About Me?',
  0.7128375768661499),
 ('Dillinger è morto', 0.7108343839645386),
 ('The Great Ecstasy of Robert Carmichael', 0.6964154839515686),
 ('Emmas Glück', 0.6804828643798828),
 ('밤과 낮', 0.6738446950912476),
 ('No Strings Attached', 0.6700600385665894),
 ('The Bachelor Party', 0.6670905351638794),
 ('Mirrors 2', 0.6669432520866394),
 ("Nora Roberts' Carolina Moon", 0.6662992835044861),
 ('$ Dollars', 0.6662683486938477),
 ('Tomorrow, When the War Began', 0.6573445200920105),
 ('Fantasma', 0.6558449864387512),
 ('Amer', 0.650338888168335),
 ('Der Räuber', 0.6501940488815308),
 ('The Prizefighter and the Lady', 0.6501848697662354),
 ('我知女人心', 0.649250864982605),
 ('Run of the Arrow', 0.6488679647445679),
 ('Handsome Harry', 0.6480263471603394)]

위의 결과를 Word2Vec, Doc2Vec 모두 생각보다는 잘 되는 것 같지 않습니다. 제가 자연어 처리를 아직 잘하지 못해서 발생한 이슈인지 정확히는 모르겠습니다. 좀 더 공부를 하고 추후에 다시 수정해보도록 하겠습니다. 다음 포스팅에서는 컨텐츠 기반 추천 시스템이 아닌 협업 필터링 기반의 추천 시스템에 대해서 본격적으로 다뤄보도록 하겠습니다.

 

donaricano-btn

커피 선물하기

댓글(3)

  • 2021.02.08 15:49

    비밀댓글입니다

    • 2021.02.08 16:29 신고

      글 좋게 봐주셔서 감사합니다. 추천관련 글 더 올려야하는데 귀찮아서 못하고 있네요.

      질문에 대한 답변을 드리면 Word2Vec하고 TF-IDF는 다른 알고리즘으로 생각하는게 맞는 것 같습니다. 근데 미디엄 글에서 나온 내용을 보면 W2V(w1)에 tf1을 곱하는 형식입니다. 즉, Word2Vec을 이용해서 Vector Embedding을 계산하고 TF-IDF의 방법을 이용해서 가중치를 제공한게 차이지 않을까 생각합니다.

  • 2021.02.09 13:49

    비밀댓글입니다

Designed by JB FACTORY