Go together

If you want to go fast, go alone. If you want to go far, go together.

파이썬 머신 러닝 완벽 가이드

콘텐츠 기반 필터링 실습 - TMDB 5000 영화 데이터 세트

NowChan 2021. 12. 13. 21:07
Content_based_Filtering

콘텐츠 기반 필터링 실습 - TMDB 5000 영화 데이터 세트

TMDB 5000 영화 데이터 세트는 IMDB 사에서 제공하는 데이터 세트입니다. https://www.kaggle.com/tmdb/tmdb-movie-metadata 에서 내려받을 수 있습니다.

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

콘텐츠 기반 필터링은 사용자가 특정 영화를 감상하고 그 영화를 좋아했다면, 그 영화와 비슷한 특성/속성, 구성요소를 가진 다른 영화를 추천하는 것입니다. 유사성을 판단하는 기준에는 장르, 감독, 배우 등이 있습니다.

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

데이터 로딩 및 가공

장르 속성을 이용해 콘텐츠 기반 필터링을 수행하겠습니다.

In [11]:
import pandas as pd
import numpy as np
import warnings; warnings.filterwarnings('ignore')

movies = pd.read_csv('/content/drive/MyDrive/military/TMDB/tmdb_5000_movies.csv')
print(movies.shape)
movies.head(1)
(4803, 20)
Out[11]:
budget genres homepage id keywords original_language original_title overview popularity production_companies production_countries release_date revenue runtime spoken_languages status tagline title vote_average vote_count
0 237000000 [{"id": 28, "name": "Action"}, {"id": 12, "name": "Adventure"}, {"id": 14, "name": "Fantasy"}, {... http://www.avatarmovie.com/ 19995 [{"id": 1463, "name": "culture clash"}, {"id": 2964, "name": "future"}, {"id": 3386, "name": "sp... en Avatar In the 22nd century, a paraplegic Marine is dispatched to the moon Pandora on a unique mission, ... 150.437577 [{"name": "Ingenious Film Partners", "id": 289}, {"name": "Twentieth Century Fox Film Corporatio... [{"iso_3166_1": "US", "name": "United States of America"}, {"iso_3166_1": "GB", "name": "United ... 2009-12-10 2787965087 162.0 [{"iso_639_1": "en", "name": "English"}, {"iso_639_1": "es", "name": "Espa\u00f1ol"}] Released Enter the World of Pandora. Avatar 7.2 11800

다양한 칼럼들 중 콘텐츠 기반 필터링 추천 분석에 사용할 주요 칼럼만 추출해 DataFrame으로 만들겠습니다. 추출할 주요 칼럼은 id, title, genres, vote_average(평균 평점), vote_count(평점 투표수), popularity(인기), keywords(영화를 설명하는 주요 키워드), overview(영화의 개요)입니다.

In [12]:
movies_df = movies[['id', 'title', 'genres', 'vote_average', 'vote_count', 'popularity',\
                    'keywords', 'overview']]

'genres', 'keywords'와 같은 칼럼은 [{"id": 28, "name": "Action"}, {"id": 12, "name": "Advanture"}]와 같은 list 내부에 여러 딕셔너리(dict)가 있는 형태의 문자열로 표기돼 있습니다. 이 칼럼이 DataFrame으로 만들어질 때는 단순히 문자열 형태로 로딩되므로 칼럼을 가공해 사용해야 합니다.

In [13]:
pd.set_option('max_colwidth', 100)
movies_df[['genres', 'keywords']][:1]
Out[13]:
genres keywords
0 [{"id": 28, "name": "Action"}, {"id": 12, "name": "Adventure"}, {"id": 14, "name": "Fantasy"}, {... [{"id": 1463, "name": "culture clash"}, {"id": 2964, "name": "future"}, {"id": 3386, "name": "sp...

genres 칼럼에서 개별 장르의 명칭은 데이터 속 딕셔너리의 키(Key)인 'name'으로 추출할 수 있습니다. Keywords 역시 마찬가지의 구조를 가집니다. genres 칼럼의 문자열을 분해해서 개별 장르를 파이썬 리스트 객체로 추출하겠습니다.

ast.literal_eval

ast 모듈의 literal_eval()함수를 이용하면 list[dict1, dict2] 객체로 만들 수 있습니다. Series 객체의 apply()에 literal_eval 함수를 적용합니다.

In [14]:
from ast import literal_eval
movies_df['genres'] = movies_df['genres'].apply(literal_eval)
movies_df['keywords'] = movies_df['keywords'].apply(literal_eval)

이제 genres 칼럼은 dict 객체들의 list로 구성돼있습니다. 이제 genres 칼럼에서 'name' 키에 해당하는 값을 추출하기 위해 apply lambda 식을 이용합니다.

In [15]:
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])
movies_df[['genres', 'keywords']][:1]
Out[15]:
genres keywords
0 [Action, Adventure, Fantasy, Science Fiction] [culture clash, future, space war, space colony, society, space travel, futuristic, romance, spa...

장르 콘텐츠 유사도 측정

0번 영화의 genres 칼럼은 [Action, Adventure ...]로 되어 있습니다. 만약 1번 영화의 genres 칼럼과 장르별 유사도를 측정하려면 어떻게 해야할까요? 가장 간단한 방법은 genres를 문자열로 변경한 뒤 CountVectorizer로 피처 벡터화한 행렬 데이터 값을 코사인 유사도로 비교하는 것입니다. 이 과정을 정리하면 아래와 같습니다.

  1. 문자열로 변환한 genres 칼럼을 Count 기반으로 피처 벡터화 변환합니다.
  2. 피처 벡터화 행렬로 변환된 데이터 세트를 코사인 유사도를 통해 비교합니다. 이를 위해 레코드 별로 타 레코드와 장르에서 코사인 유사도 값을 가지는 객체를 생성합니다.
  3. 장르 유사도가 높은 영화 중에 평점이 높은 순으로 영화를 추천합니다.
리스트 객체 내의 개별 값을 연속된 문자열로 변환하려면 일반적으로 ('구분문자').join(리스트 객체)를 사용하면 됩니다.

In [17]:
from sklearn.feature_extraction.text import CountVectorizer

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

4803개의 레코드(영화)와 276개의 단어 피처(칼럼)으로 구성된 피처 벡터 행렬이 만들어졌습니다. 이렇게 생성된 피처 벡터 행렬에 사이킷런의 cosine_siimilarity()함수로 코사인 유사도를 계산하겠습니다.


코사인 유사도는 위 사진과 같이 기준 행과 비교 행의 코사인 유사도를 행렬 형태로 반환합니다. 대각선은 자기 자신과의 유사도이므로 1이 나옵니다.

In [19]:
from sklearn.metrics.pairwise import cosine_similarity

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

genre_sim은 데이터 행(레코드)별 유사도 정보를 가지고 있습니다. 즉, 영화와 영화 간에 얼마나 비슷한 장르를 가지고 있느냐를 담고 있습니다. 콘텐츠 기반 필터링을 수행하려면 개별 레코드에 대해서 가장 장르 유사도가 높은 순으로 다른 레코드를 추출해야하는데, 이를 위해 genre_sim 객체를 사용합니다.

genr_sim 객체의 기준 행별로 유사도가 높은 행(비교 행)의 인덱스값을 추출하면 됩니다. 유사도가 아니라 비교 대상 행의 인덱스임에 주의합니다.

In [26]:
genre_sim_sorted_ind = genre_sim.argsort()[:, ::-1]
print(genre_sim_sorted_ind[:1])
[[   0 3494  813 ... 3038 3037 2401]]

반환값이 의미하는 것은 0번 레코드의 경우 3494번, 813번 ... 순으로 유사도가 높다는 뜻입니다. genre_sin_sorted_ind를 통해 특정 레코드와 유사도가 높은 다른 레코드를 추출할 수 있습니다.

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

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

In [27]:
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_name을 가진 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]

find_sim_movie() 함수를 이용해 '대부'와 유사한 영화 10개를 추천해보겠습니다.

In [28]:
similar_movies = find_sim_movie(movies_df, genre_sim_sorted_ind, 'The Godfather', 10)
similar_movies[['title', 'vote_average']]
[[2731 1243 3636 1946 2640 4065 1847 4217  883 3866]]
Out[28]:
title vote_average
2731 The Godfather: Part II 8.3
1243 Mean Streets 7.2
3636 Light Sleeper 5.7
1946 The Bad Lieutenant: Port of Call - New Orleans 6.0
2640 Things to Do in Denver When You're Dead 6.7
4065 Mi America 0.0
1847 GoodFellas 8.2
4217 Kids 6.8
883 Catch Me If You Can 7.7
3866 City of God 8.1

The Godfather: 2, Goodfellas도 대부와 비슷한 유형으로 추천할만한 영화입니다. 하지만, Light Sleeper, kids 같은 영화는 대부를 좋아하는 사람에게 추천할만한 영화는 아닙니다. Mi America는 평점이 0점인 영화로 조금 더 개선이 필요해 보입니다.

이번에는 좀 더 많은 후보군을 선정한 뒤 영화의 평점에 따라 필터링하는 방식을 최종 방식으로 정하겠습니다. 주의해야할 점은 'vote_average' 칼럼값을 이용해를 오름차순으로 정렬할 경우, 소수의 관객이 추천한 영화는 만점이 나올 확률이 높다는 문제점이 있습니다.

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

'쇼생크 탈출', '대부'보다 이름도 들은 적 없는 영화가 더 높은 평점을 가지고 있습니다. 이런 문제를 해결하기 위해 IMDB 사에서는 평가 횟수에 대한 가중치를 부여한 가중 평점을 사용합니다. 가중 평점 공식은 다음과 같습니다.
$$가중\ 평점\ \left(Weighted\ Rating\right)\ =\ C\cdot \left(\frac{v}{v+m}R\ +\ \frac{m}{v+m}\right)$$

  • v : 개별 영화의 평점 투표 횟수
  • m : 평점을 부여하기 위한 최소 투표 횟수
  • R : 개별 영화의 평균 평점
  • C : 전체 영화의 평균 평점

v는 movies_df의 vote_count 칼럼값이며,
R값은 vote_average 칼럼값입니다.
C는 movies_df['vote_average'].mean()이고,
m은 투표 횟수가 많은 영화에 더 많은 가중 평점을 부여하는 상수입니다.

m값은 전체 투표 횟수에서 상위 60%에 해당하는 횟수를 기준으로 하겠습니다. Series.quantile()을 이용해 추출합니다.

In [30]:
C = movies_df['vote_average'].mean()
m = movies_df['vote_count'].quantile(0.6)
print('C:', round(C, 3), 'm:', round(m, 3))
C: 6.092 m: 370.2

기존 평점을 가중 평점으로 바꾸는 함수 weighted_vote_average() 함수를 만들겠습니다.

In [32]:
percentile=0.6
m = movies_df['vote_count'].quantile(percentile)
C = movies_df['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_df.apply(weighted_vote_average, axis=1)

가중 평점 weighted_vote 평점이 높은 순으로 상위 10개의 영화를 추출해보겠습니다.

In [33]:
movies_df[['title', 'vote_average', 'weighted_vote', 'vote_count']].sort_values(
          'weighted_vote', ascending=False)[:10]
Out[33]:
title vote_average weighted_vote vote_count
1881 The Shawshank Redemption 8.5 8.396052 8205
3337 The Godfather 8.4 8.263591 5893
662 Fight Club 8.3 8.216455 9413
3232 Pulp Fiction 8.3 8.207102 8428
65 The Dark Knight 8.2 8.136930 12002
1818 Schindler's List 8.3 8.126069 4329
3865 Whiplash 8.3 8.123248 4254
809 Forrest Gump 8.2 8.105954 7927
2294 Spirited Away 8.3 8.105867 3840
2731 The Godfather: Part II 8.3 8.079586 3338

모두 유명한 영화들로 출력됐습니다. Spirited Away는 센과 치히로의 행방불명입니다.

find_sim_movie() 함수를 top_n의 2배수만큼 후보군을 추출하고 weighted_vote 칼럼값 순으로 정렬한 후 top_n 수만큼 영화를 출력하는 함수로 변경하겠습니다.

In [34]:
def find_sim_movie(df, sorted_ind, title_name, top_n=10):
  title_movie = df[df['title']==title_name]
  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배에 해당하는 후보군에서 weighted_vote가 높은 순으로 top_n만큼 추출
  return df.iloc[similar_indexes].sort_values('weighted_vote', ascending=False)[:top_n]

similar_movies = find_sim_movie(movies_df, genre_sim_sorted_ind, 'The Godfather', 10)
similar_movies[['title', 'vote_average', 'weighted_vote']]
Out[34]:
title vote_average weighted_vote
2731 The Godfather: Part II 8.3 8.079586
1847 GoodFellas 8.2 7.976937
3866 City of God 8.1 7.759693
1663 Once Upon a Time in America 8.2 7.657811
883 Catch Me If You Can 7.7 7.557097
281 American Gangster 7.4 7.141396
4041 This Is England 7.4 6.739664
1149 American Hustle 6.8 6.717525
1243 Mean Streets 7.2 6.626569
2839 Rounders 6.9 6.530427

이전에 추천된 영화보다 더 나은 영화가 추천됐습니다. 특히 'Once Upon a Time in America'는 대부를 좋아하는 사람이라면 대부분 좋아하는 영화입니다. 하지만, 장르만으로는 개인의 성향을 반영하기엔 무리가 있습니다. 아마 좋아하는 감독, 배우를 보고 영화를 선택하는 경우가 더 많을 것인데, 장르 뿐 아니라 감독, 배우 등을 통해 더 다양한 콘텐츠를 기반으로 하는 알고리즘으로 확장시킬 수 있을 것입니다.

궁금한 점

  • Series.apply(function)의 과정을 상세히
  • Series.quantile()

출처: 파이썬 머신러닝 완벽 가이드(권철민)
사진 출처:https://choi-kyumin.tumblr.com/post/91364657580