본문 바로가기

ML Recommendation

콘텐츠 기반 필터링 : TMDB 5000 Movie Dataset

반응형

TMDB 5000 영화 데이터 세트는 유명한 영화 데이터 정보 사이트인 IMDB의 많은 영화 중 주요 5000개 영화에 대한 메타 정보를 새롭게 가공해 캐글에서 제공하는 데이터 세트입니다.

 

아래 Site에서 tmdb_5000_credits.csv와 tmdb_5000_movies.csv 두 개의 파일을 내려받으면 됩니다.

 

TMDB 5000 Movie Dataset

Metadata on ~5,000 movies from TMDb

www.kaggle.com

 

장르 속성을 이용한 영화 콘텐츠 기반 필터팅

콘텐츠 기반 필터링은 사용자가 특정 영화를 감상하고 그 영화를 좋아했다면 그 영화와 비슷한 특성/속성, 구성 요소 등을 가진 다른 영화를 추천하는 것입니다. 가령 영화 '인셉션'을 재미있게 봤다면 '인셉션'의 장르인 액션, 공상과학으로 높은 평점을 받은 다른 영화를 추천하거나 '인셉션'의 감독인 크리스토퍼 놀란의 다른 영화를 추천하는 방식입니다.

 

이렇게 영화 간의 유사성을 판단하는 기준이 영화를 구성하는 다양한 콘텐츠(장르, 감독, 배우, 평점, 키워드, 영화 설명)를 기반으로 하는 방식이 바로 콘텐츠 기반 필터링입니다.

 

콘텐츠 기반 필터링 추천 시스템을 영화를 선택하는 데 중요한 요소인 영화 장르 속성을 기반으로 만들어 보겠습니다. 장르 칼럼 값의 유사도를 비교한 뒤 그중 높은 평점을 가지는 영화를 추천하는 방식입니다.

 

 

데이터 로딩 및 가공

import warnings; warnings.filterwarnings('ignore')
pd.set_option('display.max_rows', None)
pd.set_option('display.max_colwidth', None)

movies = pd.read_csv(r'C:\Users\HANA\PycharmProjects\HANATOUR\Recommendation\TEXT\tmdb_5000_movies.csv')
# print(movies.shape)
# print(movies.head(10))

movies_df = movies[['id','title','genres','vote_average', 'vote_count', 'popularity', 'keywords', 'overview']]
print(movies_df['genres'].head(1))
print(movies_df['keywords'].head(1))

우선 장르 속성을 이용해 콘테츠 기반 필터링을 수행하겠습니다. tmdb_5000_movies.csv 파일을 읽습니다. 이 파일에는 영화 제목, 개요, 인기도, 평점, 투표 스, 예산, 키워드 등 영화에 대한 다양한 메타 정보를 가지고 있습니다.

 

이 중 콘텐츠 기반 필터링 추천 분석에 사용할 주요 칼럼만 추출해 새롭게 DataFrame으로 만들면 됩니다. 이 때 genres와 keywords는 파이썬 리스트(list) 내부에 여러 개의 딕셔너리(dict)가 있는 형태의 문자열로 표기돼 있습니다. 이는 한꺼번에 여러 개의 값을 표현하기 위한 표기 방식입니다. 아래와 같이 구성되어 있습니다.

 

print(movies_df['genres'].head(1))의 경우 - 

 [{"id": 28, "name": "Action"}, {"id": 12, "name": "Adventure"}, {"id": 14, "name": "Fantasy"}, {"id": 878, "name": "Science Fiction"}]

 

print(movies_df['keywords'].head(1))의 경우 - 

[{"id": 1463, "name": "culture clash"}, {"id": 2964, "name": "future"}, {"id": 3386, "name": "space war"}, {"id": 3388, "name": "space colony"}, {"id": 3679, "name": "society"}, {"id": 3801, "name": "space travel"}, {"id": 9685, "name": "futuristic"}, {"id": 9840, "name": "romance"}, {"id": 9882, "name": "space"}, {"id": 9951, "name": "alien"}, {"id": 10148, "name": "tribe"}, {"id": 10158, "name": "alien planet"}, {"id": 10987, "name": "cgi"}, {"id": 11399, "name": "marine"}, {"id": 13065, "name": "soldier"}, {"id": 14643, "name": "battle"}, {"id": 14720, "name": "love affair"}, {"id": 165431, "name": "anti war"}, {"id": 193554, "name": "power relations"}, {"id": 206690, "name": "mind and soul"}, {"id": 209714, "name": "3d"}]

 

두 Feature의 값을 정리하기 위해 파이썬 ast모듈의 ilteral_eval()함수를 이용하면 이 문자열을 문자열이 의미하는 list [dict1, dict2] 객체로 만들 수 있습니다. Series객체의 apply()에 literal_eval함수를 적용해 문자열을 객체로 반환합니다. 이후 각 칼럼에 해당하는 값을 추출하기 위해 apply lambda 식을 이용합니다. 

 

아래는 일련의 과정입니다.

from ast import literal_eval
movies_df['genres'] = movies_df['genres'].apply(literal_eval)
movies_df['keywords'] = movies_df['keywords'].apply(literal_eval)
movies_df['genres'] = movies_df['genres'].apply(lambda x : [y['name'] for y in x])
movies_df['keywords'] = movies_df['keywords'].apply(lambda x : [y['name'] for y in x])
print(movies_df['genres'].head(1))
print(movies_df['keywords'].head(1))

 

print(movies_df['genres'].head(1))의 경우 - 

[Action, Adventure, Fantasy, Science Fiction]

 

print(movies_df['keywords'].head(1))의 경우 - 

[culture clash, future, space war, space colony, society, space travel, futuristic, romance, space, alien, tribe, alien planet, cgi, marine, soldier, battle, love affair, anti war, power relations, mind and soul, 3d]

 

 

장르 콘테츠 유사도 측정

위에서 말했지만, genres칼럼 여러 개의 개별 장르가 리스트로 구성돼 있습니다. 만약 영화 A의 genres가 [Action, Adventure, Fantasy, Science Fiction]으로 돼 있고, 영화 B의 generes가 [Action, Adventure, Fantasy]으로 돼 있다면 어떻게 장르별 유사도를 측정할까요?

가장 간단한 방법은 genres를 문자열로 변경한 뒤 이를 CountVectorizer로 피처 벡터화한 행렬 데이터 값을 코사인 유사도로 비교하면 됩니다. 아래의 단계로 구현합니다.

 

  • 문자열로 변환된 generes칼럼을 Count 기반으로 피처 벡터화 변환합니다.
  • generes문자열을 피처 벡터화 행렬로 변환한 데이터 세트를 코사인 유사도를 통해 비교합니다. 이를 위해 데이터 세트의 레코드 별로 타 레코드와 장르에서 코사인 유사도 값을 가지는 객체를 생성합니다.
  • 장르 유사도가 높은 영화 중에 평점이 높은 순으로 영화를 추천합니다.

 

먼저 generes칼럼을 문자열로 변환한 뒤 사이킷런의 CountVectorizer를 이용해 피처 벡터 행렬로 만든 후 리스트 객체 값으로 구성된 generes칼럼을 공백 문자로 구분하는 문자열로 변환해 별도의 칼럼인 generes_literal에 저장합니다.

from sklearn.feature_extraction.text import CountVectorizer

#CountVectorizer를 적용하기 위해 공백문자로 word단위가 구분되는 문자열로 변환.
movies_df['generes_literal'] = movies_df['genres'].apply(lambda x :  (' ').join(x))
print(movies_df['generes_literal'].head(10))
count_vect = CountVectorizer(min_df=0, ngram_range=(1,2))
genre_mat = count_vect.fit_transform(movies_df['generes_literal'])
print(genre_mat)
print(genre_mat.shape)

 

shape의 값이 (4803, 276)으로 4803개의 레코드와 276개의 개별 단어 피처로 구성된 피처 벡터 행렬이 만들어졌습니다. 이렇게 한 후 cosine_similiaity를 이용해 코사인 유사도를 계산합니다.

 

from sklearn.metrics.pairwise import cosine_similarity

genre_sim = cosine_similarity(genre_mat, genre_mat)
print(genre_sim.shape)
print(genre_sim[:1])
(4803, 4803)
[[1.         0.59628479 0.4472136  ... 0.         0.         0.        ]]

cosine_similarities()호출로 생성된 genere_sim 객체는 movie_df의 genre_literal칼럼을 피처 벡터화한 행렬(genre_mat) 데이터의 행(레코드) 별 유사도 정보를 가지고 있으며, 결국은 movies_df DataFrame의 행렬 장르 유사도 값을 가지고 있는 것입니다.

 

movies_df를 장르 기준으로 콘텐츠 기반 필터링을 수행하려면 movies_df의 개별 레코드에 대해서 가장 자르 유사도가 높은 순으로 다른 레코드를 추출해야 하는데, 이를 위해 앞에서 생성한 genre_sim 객체를 이용합니다.

 

genre_sim객체의 기준 행별로 비교 대상이 되는 행의 유사도 값이 높은 순으로 정렬된 행렬의 위치 인덱스 값을 추출하면 됩니다. 값이 높은 순으로 비교 대상 행의 유사도 값이 아니라 비교 대상행의 위치 인덱스임에 주의하기 바랍니다.

 

넘파이의 argsort() 함수를 이용하면 유사도가 높은 순으로 정리된 genre_sim객체의 비교 행 위치 인덱스 값을 간편하게 얻을 수 있습니다. 

 

genre_sim_sorted_ind = genre_sim.argsort()[:,::-1]
print(genre_sim_sorted_ind[:1])

아래의 의미는 0번 레코드의 경우 자신인 0번 레코드를 제외하면 3494번 레코드가 가장 유사도가 높고, 그다음이 813번 레코드이며 유사도가 가장 낮은 레코드는 2401번이라는 의미입니다.

[[1.         0.59628479 0.4472136  ... 0.         0.         0.        ]]
[[   0 3494  813 ... 3038 3037 2401]]

 

장르 콘텐츠 필터링을 이용한 영화 추천

이제 장르 유사도에 따라 영화를 추천하는 함수를 생성하겠습니다. 

def find_sim_movie(df, sorted_ind, title_name, top_n=10):
    #인자로 입력된 movies_df DataFrame에서 'title'칼럼이 입력된 title_name값인 DataFrame 추출
    title_movie = df[df['title'] == title_name]

    #title_named을 가진 DataFrame의 index객체를 ndarray로 반환하고
    #sorted_ind 인자로 입력된 genre_sim_sorted_ind 객체에서 유사도 순으로 top_n개의 index추출
    title_index = title_movie.index.values
    similar_indexes = sorted_ind[title_index, :(top_n)]

    #추출된 top_n index출력, top_n index는 2차원 데이터임
    #dataframe에서 index로 사용하기 위해서 1차원 array로 변경
    print(similar_indexes)
    similar_indexes = similar_indexes.reshape(-1)

    return df.iloc[similar_indexes]

similar_movies = find_sim_movie(movies_df, genre_sim_sorted_ind, 'The Godfather', 10)
print(similar_movies.info())
print(similar_movies)
print(similar_movies[['title', 'vote_count', 'vote_average']])

 

아래의 결과를 보면 '대부 2편', '좋은친구들(Goodfellas)'와 같은 유형이 높은 평점을 보이고 있으나 이해할 수 없는 값도 나오고 있습니다.

                                               title  vote_count  vote_average
2731                          The Godfather: Part II        3338           8.3
1243                                    Mean Streets         345           7.2
3636                                   Light Sleeper          15           5.7
1946  The Bad Lieutenant: Port of Call - New Orleans         326           6.0
2640         Things to Do in Denver When You're Dead          85           6.7
4065                                      Mi America           0           0.0
1847                                      GoodFellas        3128           8.2
4217                                            Kids         279           6.8
883                              Catch Me If You Can        3795           7.7
3866                                     City of God        1814           8.1

 

이번에는 vote_average값을 기본으로 선정해보겠습니다. 주의할 점은 0부터 10점까지의 점수로 돼 있지만, 관객이 1~2명의 소수의 관객이 특정 영화에 만점이나 매우 높은 평점을 부여해 왜곡된 데이터를 가지고 있습니다. 이를 확인하기 위해 sort_values() 오름차순으로 movies_df를 정렬해서 10개만 출력해 보겠습니다.

print(movies_df[['title', 'vote_count', 'vote_average']].sort_values('vote_average', ascending=False)[:10])

 

아래와 같습니다. 이상하죠. 이제 가중치를 부여해서 확인해보겠습니다.

                         title  vote_count  vote_average
3519          Stiff Upper Lips           1          10.0
4247     Me You and Five Bucks           2          10.0
4045     Dancer, Texas Pop. 81           1          10.0
4662            Little Big Top           1          10.0
3992                 Sardaarji           2           9.5
2386            One Man's Hero           2           9.3
2970        There Goes My Baby           2           8.5
1881  The Shawshank Redemption        8205           8.5
2796     The Prisoner of Zenda          11           8.4
3337             The Godfather        5893           8.4

 

가중 평점(Weighted Rating) = (v/v+m))*R + (m/(v+m))*C를 수행하여 실행해봅니다.

  • v: 개별 영화에 평점을 투표한 횟수

  • m: 평점을 부여하기 위한 최소 투표 횟수

  • R: 개별 영화에 대한 평균 평점

  • C: 전체 영화에 대한 평균 평점

percentile = 0.6
m = movies['vote_count'].quantile(percentile)
C = movies['vote_average'].mean()

def weighted_vote_average(record):
    v = record['vote_count']
    R = record['vote_average']

    return ( (v/(v+m)) * R) + ( (m/(m+v))*C )

movies_df['weighted_vote'] = movies.apply(weighted_vote_average, axis=1)
movies_df[['title', 'weighted_vote', 'vote_average', 'vote_count']].sort_values('weighted_vote', ascending=False)[:10]
print(movies_df['title'].head(100))

def find_sim_movie_weight(df, sorted_ind, title_name, top_n=10):
    #인자로 입력된 movies_df DataFrame에서 'title'칼럼이 입력된 title_name값인 DataFrame 추출
    title_movie = df[df['title'] == title_name]
    print(title_movie)
    title_index = title_movie.index.values

    #top_n의 2배에 해당하는 장르 유사성이 높은 인덱스 추출
    similar_indexes = sorted_ind[title_index, :(top_n*2)]
    similar_indexes = similar_indexes.reshape(-1)

    #기준 영화 인덱스는 제외
    similar_indexes = similar_indexes[similar_indexes != title_index]


    #top_n의 2배에 해당하는 후보군에서 weight_vote가 노은 순으로 top_n만큼 추출
    return df.iloc[similar_indexes].sort_values('weighted_vote', ascending=False)[:top_n]

similar_movies = find_sim_movie_weight(movies_df, genre_sim_sorted_ind, 'Inception', 10)
print(similar_movies[['title', 'vote_average', 'weighted_vote']])
반응형