02. 컨텐츠 기반 추천시스템 - TF-IDF를 이용한 추천시스템

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

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

TF-IDF는 특정 문서 내에 특정 단어가 얼마나 자주 등장하는 지를 의미하는 단어 빈도(TF)와 전체 문서에서 특정 단어가 얼마나 자주 등장하는지를 의미하는 역문서 빈도(DF)를 통해서 “다른 문서에서는 등장하지 않지만 특정 문서에서만 자주 등장하는 단어"를 찾아서 문서 내 단어의 가중치를 계산하는 방법입니다.


용도로는 문서의 핵심어를 추출, 문서들 사이의 유사도를 계산, 검색 결과의 중요도를 정하는 작업등에 활용할 수 있습니다. 각각의 용어부터 먼저 살펴보도록 하겠습니다.

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

TF-IDF를 사용하는 이유

  1. Item이라는 컨텐츠를 벡터로 "Feature Extract" 과정을 수행해준다.
  2. 빈도수를 기반으로 많이 나오는 중요한 단어들을 잡아준다. 이러한 방법을 Counter Vectorizer라고 한다.
  3. 하지만, Counter Vectorizer는 단순 빈도만을 계산하기에 조사, 관사처럼 의미는 없지만 문장에 많이 등장하는 단어들도 높게 쳐주는 한계가 있다. 이러한 단어들(I, this, movie 등)에는 페널티를 줘서 적절하게 중요한 단어만을 잡아내는 게 TF-IDF 기법이다.

TF-IDF 예시

아래와 같이 4개의 문서에 대해서 TF-IDF를 한번 수행해보도록 하겠습니다.

문서 내용
0 먹고 싶은 사과
1 먹고 싶은 바나나
2 길고 노란 바나나 바나나
3 저는 과일이 좋아요

먼저, TF인 문서 내 단어의 횟수를 계산하도록 하겠습니다. 아래의 표가 특정 문서 d에서의 특정 단어 t의 등장 횟수를 표현한 것이고 Counter Vectorizer의 결과와 동일한 정보입니다. TF(Counter Vectorizer)만으로 생성된 벡터 간의 유사도를 계산해서 추천을 해도 되지만 조사와 관사처럼 의미가 없이 등장하는 단어들도 높게 쳐주는 한계가 있습니다.

  과일이 길고 노란 먹고 바나나 사과 싶은 저는 좋아요
문서1 0 0 0 1 0 1 1 0 0
문서2 0 0 0 1 1 0 1 0 0
문서3 0 1 1 0 2 0 0 0 0
문서4 1 0 0 0 0 0 0 1 1

이에 대한 페널티를 주기 위해서 특정 단어 t가 등장한 문서의 수인 "DF"값을 계산하도록 하겠습니다. DF를 보면 "바나나"와 같은 단어는 전체 문서에서 3번 정도로 등장하는 것을 볼 수 있습니다.

  과일이 길고 노란 먹고 바나나 사과 싶은 저는 좋아요
총합 1 1 1 2 3 1 2 1 1

이렇게 계산한 DF의 역수를 통해서 IDF값을 계산하게 됩니다. 이때, IDF는 사용자가 설정한 정의에 따라서 조금 달라지게 됩니다. 보통은 아래와 같이 역수에 n을 곱한 후에 log를 취한 값을 많이 사용합니다.


이에 대한 기준으로 IDF 값을 계산하면 아래의 표와 같습니다.

단어 IDF(역 문서 빈도)
과일이 ln(4/(1+1)) = 0.693147
길고 ln(4/(1+1)) = 0.693147
노란 ln(4/(1+1)) = 0.693147
먹고 ln(4/(2+1)) = 0.287682
바나나 ln(4/(3+1)) = 0
사과 ln(4/(1+1)) = 0.693147
싶은 ln(4/(2+1)) = 0.287682
저는 ln(4/(1+1)) = 0.693147
좋아요 ln(4/(1+1)) = 0.693147

이를 이제 TF와 곱해주면 TF-IDF의 값이 생성됩니다.

  과일이 길고 노란 먹고 바나나 사과 싶은 저는 좋아요
문서1 0 0 0 0.2876 0 0.6931 0.2876 0 0
문서2 0 0 0 0.2876 0 0 0.2876 0 0
문서3 0 0.6931 0.6931 0 0 0 0 0 0
문서4 0.6931 0 0 0 0 0 0 0.6931 0.6931

마지막으로 이전 포스팅에서 배운 코사인 유사도를 통해서 문서 간의 유사도를 계산하면 문서 1과 문서 2가 유사하다는 결론을 내릴 수 있습니다.

  문서1 문서2 문서3 문서4
문서1 1 0.5061 0 0
문서2 0.5061 1 0 0
문서3 0 0 1 0
문서4 0 0 0 1

[장점]

  • 직관적인 해석이 가능함

[단점]

  • 대규모 말뭉치를 다룰 때 메모리 상의 문제가 발생
    • 높은 차원을 가짐
    • 매우 sparse 한 형태의 데이터임

한번 코드를 통해서 위의 과정을 살펴보도록 하겠습니다.

# 먼저 위의 예시에서 사용한 문서목록을 가져옵니다. 각각의 문장이 하나의 문서형태가 됩니다. 
docs = [
  '먹고 싶은 사과', # 문서0 
  '먹고 싶은 바나나', # 문서1
  '길고 노란 바나나 바나나', # 문서2 
  '저는 과일이 좋아요' # 문서3 
]

TF-IDF를 보기 전에 먼저, TF를 구성하는 Counter Vectorizer부터 보도록 하겠습니다. 둘의 코드가 거의 비슷해서 Counter Vectorizer만 이해해도 TF-IDF 코드를 작성하는 데에는 문제가 없을 것입니다. Counter Vectorizer는 sklearn의 패키지에서 가져와서 사용합니다. 먼저, vect라는 객체를 생성해줍니다.

from sklearn.feature_extraction.text import CountVectorizer
vect = CountVectorizer() # Counter Vectorizer 객체 생성

이후, 문장을 Vector 형태로 바꾸는 Counter Vectorizer를 수행합니다. 이후, countvect를 출력하면 4x9 형태의 크기를 가지는 행렬이 되는 것을 볼 수 있습니다. 여기서 4는 문서의 개수를 9는 단어의 개수를 의미합니다.

# 문장을 Counter Vectorizer 형태로 변형 
countvect = vect.fit_transform(docs) 
countvect # 4x9 : 4개의 문서에 9개의 단어 
<4x9 sparse matrix of type '<class 'numpy.int64'>'
    with 12 stored elements in Compressed Sparse Row format>

. toarray() 함수를 통해서 한번 문서들이 어떤 형태로 Vector화 되었는지 살펴보도록 하겠습니다.

# toarray()를 통해서 문장이 Vector 형태의 값을 얻을 수 있음 
# 하지만, 각 인덱스와 컬럼이 무엇을 의미하는지에 대해서는 알 수가 없음 
countvect.toarray()
array([[0, 0, 0, 1, 0, 1, 1, 0, 0],
       [0, 0, 0, 1, 1, 0, 1, 0, 0],
       [0, 1, 1, 0, 2, 0, 0, 0, 0],
       [1, 0, 0, 0, 0, 0, 0, 1, 1]], dtype=int64)

값이 0, 1, 2으로 문서 내의 단어가 등장한 횟수에 따라서 부여된 것을 볼 수 있습니다. 하지만, 각 인덱스와 칼럼이 무엇을 의미하는지에 대해서는 알 수가 없습니다. 이를 확인하기 위해 객체가 가지고 있는 vocabulary_ 정보를 추출해서 정보를 봐야 합니다.

vect.vocabulary_
{'먹고': 3,
 '싶은': 6,
 '사과': 5,
 '바나나': 4,
 '길고': 1,
 '노란': 2,
 '저는': 7,
 '과일이': 0,
 '좋아요': 8}

dictionary 형태의 값을 가지게 되는데, 각 value가 의미하는 게 칼럼의 위치가 되고 key는 해당 컬럼의 단어를 의미합니다. ("과일이"는 행렬의 0번째 컬럼을 의미하는 값입니다.) 이를 보기 좋게 하기위해서 value를 기준으로 정렬을 해보도록 하겠습니다.

sorted(vect.vocabulary_)
['과일이', '길고', '노란', '먹고', '바나나', '사과', '싶은', '저는', '좋아요']

위의 정보를 가지고, Matrix에 인덱스와 컬럼의 값을 부여해주도록 하겠습니다. pandas 패키지를 이용해서 데이터 프레임 형태로 값을 변경하도록 하겠습니다.

import pandas as pd
countvect_df = pd.DataFrame(countvect.toarray(), columns = sorted(vect.vocabulary_))
countvect_df.index = ['문서1', '문서2', '문서3', '문서4']
countvect_df

이제 각 Vector의 유사도를 계산하면 아래와 같습니다.

# 위의 Data Frame 형태의 유사도를 계산 
from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(countvect_df, countvect_df)
array([[1.        , 0.66666667, 0.        , 0.        ],
       [0.66666667, 1.        , 0.47140452, 0.        ],
       [0.        , 0.47140452, 1.        , 0.        ],
       [0.        , 0.        , 0.        , 1.        ]])

즉, 위의 결과를 통해서 0번 문서는 1번과 유사하다는 결론을 얻을 수 있습니다. 동일한 방식으로 TF-IDF를 수행하면 아래와 같습니다. 객체를 생성하는 부분의 함수가 CounterVectorizer에서 TfidfVectorizer으로 바뀐 것 외에는 차이가 없습니다.

from sklearn.feature_extraction.text import TfidfVectorizer
vect = TfidfVectorizer()
tfvect = vect.fit(docs)
tfidv_df = pd.DataFrame(tfvect.transform(docs).toarray(), columns = sorted(vect.vocabulary_))
tfidv_df.index = ['문서1', '문서2', '문서3', '문서4']
tfidv_df

from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(tfidv_df, tfidv_df)
array([[1.        , 0.57833696, 0.        , 0.        ],
       [0.57833696, 1.        , 0.40894599, 0.        ],
       [0.        , 0.40894599, 1.        , 0.        ],
       [0.        , 0.        , 0.        , 1.        ]])

하지만, 위의 값은 이론 부분에서 살폈던 내용과 값이 조금 다릅니다. 그 이유는 잠깐 언급했던 smooth_idf 부분에 있습니다. Sklearn의 TF-IDF는 IDF를 계산할 때 +1 부분이 분모에 있는 게 아니라 log의 밖에서 더해주기 때문에 계산이 조금 달라지게 됩니다.


추가적으로, TF-IDF와 CounterVectorizer 모두 문서 내에 등장한 모든 단어들을 가지고 벡터를 만드는 특징이 있습니다. 그렇기에, 문서가 많아지면 많아질수록 전체 단어가 많아질수록 벡터의 차원은 커지고 행렬 또한 감당할 수 없을 정도가 될 것입니다. 이러한 문제를 방지해주기 위해서, 가장 많이 나온 단어 n 개만 사용하라는 `max_features` 파라미터들이 존재합니다.

from sklearn.feature_extraction.text import TfidfVectorizer
vect = TfidfVectorizer(max_features=4)
tfvect = vect.fit(docs)

tfidv_df = pd.DataFrame(tfvect.transform(docs).toarray(), columns = sorted(vect.vocabulary_))
tfidv_df.index = ['문서1', '문서2', '문서3', '문서4']
tfidv_df

max_features를 4로 설정한 결과 4개의 단어 토큰만을 가지고 TF-IDF를 만든 것을 볼 수 있습니다. 위의 데이터가 아닌 영화 평점 데이터를 가지고 추천 시스템을 한번 만들어보겠습니다. 해당 예제는 딥러닝을 이용한 자연어 처리 입문의 코드를 기반으로 작성되었고, 데이터는 다음의 링크에서 받을 수 있습니다.

import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

먼저, 위의 링크에서 데이터를 받고 movies_metadata를 불러오도록 하겠습니다.

# 경로의 경우 각자의 환경에 맞게 설정해주면 됩니다. 
path = 'C:/Users/User/Desktop/추천시스템 입문하기/05. 추천시스템 실습하기/input/movies/'
data = pd.read_csv(path + 'movies_metadata.csv', low_memory=False)
data.head(2)

# 컬럼 확인 
data.columns
Index(['adult', 'belongs_to_collection', 'budget', 'genres', 'homepage', 'id',
       'imdb_id', 'original_language', 'original_title', 'overview',
       'popularity', 'poster_path', 'production_companies',
       'production_countries', 'release_date', 'revenue', 'runtime',
       'spoken_languages', 'status', 'tagline', 'title', 'video',
       'vote_average', 'vote_count'],
      dtype='object')

해당 데이터는 다양한 칼럼들을 가지고 있는데, 여기서 Overview의 항목만을 가지고 TF-IDF를 통해 유사한 영화들을 찾아보도록 하겠습니다. 먼저, 영화에 overview가 없는 영화들이 있어서 해당 영화는 삭제해주도록 하겠습니다.

# 전처리 
# overview의 결측치가 있는 항목은 모두 제거 
data = data[data['overview'].notnull()].reset_index(drop=True)
data.shape

이후, overview에 대한 metadata에 TF-IDF를 적용해줍니다. 이때, stop_words라는 것을 적용해주는데 이는 한국어로는 불용어로 and, i, me처럼 유의미하지 않은 단어 토큰을 제거해주라는 의미입니다. (idf가 비슷한 역할을 하지만, idf는 어느 정도 페널티만 주는 반면 불용어는 아예 삭제하는 차이가 있습니다.)

# 불용어 : 유의미하지 않은 단어 토큰을 제거 
# https://wikidocs.net/22530
tfidf = TfidfVectorizer(stop_words='english')

# overview에 대해서 tf-idf 수행
tfidf_matrix = tfidf.fit_transform(data['overview'])
print(tfidf_matrix.shape)
(44512, 75827)

이제 cosine_similarity 함수를 통해서 유사도를 측정합니다. 여기서 메모리 에러가 많이 발생할 수 있는데, tfidf = TfidfVectorizer(stop_words='english', max_features=10000)를 통해서 TF-IDF의 크기를 줄이든가 data = data.loc[0:10000].reset_index(drop=True) 영화 자체의 수를 줄이는 방법으로 메모리 문제를 해결할 수 있습니다. (근본적으로 해결하려면 더 좋은 컴퓨터를... 사야 합니다)


참고로 이때에도 단순하게 영화 자체의 수를 줄이는 것보다는 사람들이 많이 볼 것 같은 영화들만 잘 추려서 줄이는 게 성능 향상에 실제로 많은 도움이 됩니다. (인기 있는 영화, 최신 영화 등)

from sklearn.metrics.pairwise import cosine_similarity
cosine_matrix = cosine_similarity(tfidf_matrix, tfidf_matrix)

# 만일 여기서 메모리 에러가 발생하신 분은 TF-IDF의 파라미터를 수정해줘서 다시 돌리면 됩니다. 
# tfidf = TfidfVectorizer(stop_words='english', max_features=10000)

# 그래도, 안되는 경우에는 문서의 수를 조금 줄여서 실행해보시길 바랍니다. 
# data = data.loc[0:10000].reset_index(drop=True)
(44512, 44512)

유사도 매트릭스를 찍어보면 아래와 같이 모든 영화에 대해 유사도가 계산되었습니다. 하지만, 해당 인덱스가 어떤 영화인지를 알 수가 없는 불편함이 있습니다.

np.round(cosine_matrix, 4)
array([[1.    , 0.015 , 0.    , ..., 0.    , 0.0059, 0.    ],
       [0.015 , 1.    , 0.0468, ..., 0.    , 0.022 , 0.0092],
       [0.    , 0.0468, 1.    , ..., 0.    , 0.014 , 0.    ],
       ...,
       [0.    , 0.    , 0.    , ..., 1.    , 0.    , 0.    ],
       [0.0059, 0.022 , 0.014 , ..., 0.    , 1.    , 0.    ],
       [0.    , 0.0092, 0.    , ..., 0.    , 0.    , 1.    ]])

그래서, movie title과 id를 매핑할 dictionary를 생성해주도록 하겠습니다. 파이썬에서 간단하게 enumerate를 이용해서 만들어 줄 수 있습니다. (참고 : enumerate는 for문을 돌 때 for문의 순서와 for문안의 값을 같이 출력해주는 함수입니다. 예를 들어, enumerate(data['title'])의 경우는 (0, 'Toy Story'), (1, 'Jumanji')... 과 같이 출력이 됩니다.)

# movie title와 id를 매핑할 dictionary를 생성해줍니다. 
movie2id = {}
for i, c in enumerate(data['title']): movie2id[i] = c

# id와 movie title를 매핑할 dictionary를 생성해줍니다. 
id2movie = {}
for i, c in movie2id.items(): id2movie[c] = i

이렇게 생성한 dictionary를 출력해보면 아래와 같이 movie tile과 id가 잘 매핑된 것을 볼 수 있습니다.

movie2id

id2movie

마지막으로 이렇게 생성한 유사도 함수를 통해서 상위 10개의 영화 추천을 진행해보도록 하겠습니다. 먼저, id2movie의 dictionary에서 원하는 영화 'Toy Story'의 인덱스를 추출합니다. 이러면 idx는 0이 나오게 됩니다. 이 idx 값을 가지고 이제 cosine_matrix에서 0번 idx를 가지는 영화와 유사도 값 및 인덱스를 추출해줍니다. 이때, 자기 자신의 경우 유사도가 무조건 1이므로 해당 인덱스는 if문을 통해 제거해주도록 합니다.


그리고, sorted를 통해서 유사도가 높은 순서대로 정렬한 후에 출력해보면 인덱스 번호와 유사도 값을 얻을 수 있습니다.

# Toy Story의 id 추출 
idx = id2movie['Toy Story'] # Toy Story : 0번 인덱스 
sim_scores = [(i, c) for i, c in enumerate(cosine_matrix[idx]) if i != idx] # 자기 자신을 제외한 영화들의 유사도 및 인덱스를 추출 
sim_scores = sorted(sim_scores, key = lambda x: x[1], reverse=True) # 유사도가 높은 순서대로 정렬 
sim_scores[0:10] # 상위 10개의 인덱스와 유사도를 추출 
[(15282, 0.5321733978946077),
 (2979, 0.47214559370670484),
 (10271, 0.274962516260823),
 (24316, 0.27322653023092314),
 (23646, 0.23543946958082806),
 (28893, 0.22397858775140161),
 (42572, 0.21761842522811847),
 (37778, 0.2159367770908928),
 (41893, 0.20190977282766223),
 (8303, 0.19868494439439036)]

마지막으로 해당 인덱스 번호를 movie2id를 통해서 title로 변경해주면 Toy Story에 대해 어떤 영화들이 추천되었는지 볼 수 있습니다. Toy Story의 경우 Toy Story 3과 2가 추천된 것으로 굉장히 잘 나왔습니다.

# 인덱스를 Movie Title로 변환 
sim_scores = [(movie2id[i], score) for i, score in sim_scores[0:10]]
sim_scores
[('Toy Story 3', 0.5321733978946077),
 ('Toy Story 2', 0.47214559370670484),
 ('The 40 Year Old Virgin', 0.274962516260823),
 ('Small Fry', 0.27322653023092314),
 ("Andy Hardy's Blonde Trouble", 0.23543946958082806),
 ('Hot Splash', 0.22397858775140161),
 ('Andy Kaufman Plays Carnegie Hall', 0.21761842522811847),
 ('Superstar: The Life and Times of Andy Warhol', 0.2159367770908928),
 ('Andy Peters: Exclamation Mark Question Point', 0.20190977282766223),
 ('The Champ', 0.19868494439439036)]

 

donaricano-btn

커피 선물하기

 

[참고자료]

댓글(0)

Designed by JB FACTORY