Patrick's 데이터 세상

[추천 시스템 - 장르 속성 콘텐츠 필터링 추천] TMDB 5000 영화 데이터 세트 본문

Machine Learning/Recommend system

[추천 시스템 - 장르 속성 콘텐츠 필터링 추천] TMDB 5000 영화 데이터 세트

patrick610 2020. 9. 3. 23:36
반응형
SMALL

 

 

 

 

본 포스팅은 캐글(Kaggle)에서 제공하는 'TMDB 5000 영화 데이터 세트'를 활용하여 콘텐츠 기반 필터링을 실습하기 위한 목적으로 작성하였습니다.


Git url

github.com/hipster4020/RecommendationSystem/blob/master/ContentsBasedFiltering_Movies.ipynb

 

hipster4020/RecommendationSystem

Contribute to hipster4020/RecommendationSystem development by creating an account on GitHub.

github.com

 

분석 도구 : Google Colaboratory

 

활용 데이터유명한 영화 데이터 정보 사이트인 IMDB의 많은 영화 중 주요 5000개 영화에 대한 정보를 새롭게 가공한 메타 데이터.

https://www.kaggle.com/tmdb/tmdb-movie-metadata

 

TMDB 5000 Movie Dataset

Metadata on ~5,000 movies from TMDb

www.kaggle.com

 


 

 

 

먼저, 실습에 앞서 간단한 콘텐츠 기반 필터링에 대한 설명입니다.

 

콘텐츠 기반 필터링(Contents Based Filtering)

사용자가 특정 아이템을 선호하는 경우 그 아이템과 비슷한 콘텐츠를 가진 다른 아이템을 추천해주는 방식.

 

 

 

콘텐츠 기반 필터링의 개념은 매우 간단한데 콘텐츠 정보를 기반으로 다른 콘텐츠를 추천하는 방식입니다.
콘텐츠 자체를 분석하는 방식이기 때문에 초기의 사용자 행동 데이터가 적더라도 추천할 수 있는 장점이 있습니다.

 

 

 

 

 

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

 

  ⊙ 데이터 로딩 및 가공

# Library load
import pandas as pd
import numpy as np
import warnings; warnings.filterwarnings('ignore')

from ast import literal_eval
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity
movies=pd.read_csv("/content/sample_data/tmdb_5000_movies.csv")
print(movies.shape)
movies.head(1)

결과

 

 

tmdb_5000_movies.csv는 4,803개의 레코드와 20개의 피처로 구성되어 있습니다.
영화 제목, 개요, 인기도, 평점, 투표수 등 주요 정보 중 콘텐츠 기반 필터링 추천 분석에 사용할 주요 컬럼만 추출해 새롭게 DataFrame로 만듭니다.

 

# null 값 체크
movies.isnull().sum()

결과

 

 

데이터 분석 전 null 값을 체크합니다.
id, title, genres, vote_average, vote_count  등 사용할 컬럼에는 null 값이 없기 때문에 그대로 사용합니다.

만일 null 값이 있다면 협의에 의해 평균 값으로 대체하거나, 레코드를 삭제 처리합니다.

 

# 주요 컬럼 추출
movies_df=movies[['id', 'title', 'genres', 'vote_average', 'vote_count']]
movies_df.head(5)

결과

 

 

해당 데이터 세트에서 genres 컬럼은 리스트(list) 안에 여러 개의 키 값을 가지고 있는 딕셔너리(dict) 형태 문자열로 되어있습니다.

따라서, 이 컬럼을 가공하여 처리해야 합니다.

 

# 컬럼 길이 100으로 세팅
pd.set_option('max_colwidth', 100)
movies_df[['genres']][:1]

결과

 

 

컬럼 길이를 좀 더 넓혀 genres 컬럼의 리스트를 확인해보면 각각 'name' 키 값을 가지고 있어 해당 키로 추출할 수 있습니다.

 

 

# apply()에 literal_eval 함수를 적용해 문자열을 객체로 변경
movies_df['genres']=movies_df['genres'].apply(literal_eval)
movies_df.head(1)

결과

 

 

ast 모듈의 literal_eval() 함수를 이용하여 각 문자열을 문자열이 의미하는 list[dic1, dic2] 객체로 만들 수 있습니다.

Series 객체의 apply()에 literal_eval 함수를 적용해 문자열을 객체로 변경합니다.

 

현재는 문자열이 아닌 리스트에 각 딕셔너리를 지니고 있는 형태입니다.

이제 genres 컬럼에서 'name' 키에 해당하는 값을 추출하기 위해 apply lambda 식을 이용합니다.

# apply lambda를 이용하여 리스트 내 여러 개의 딕셔너리의 'name' 키 찾아 리스트 객체로 변환.
movies_df['genres']=movies_df['genres'].apply(lambda x : [ y['name'] for y in x])
movies_df[['genres']][:1]

결과

 

 

 

장르 콘텐츠 유사도 측정

 

각각 영화의 장르별로 유사도를 측정하는 방법에 대해 알아보겠습니다.

movies_df[['genres']]

결과

장르 컬럼을 보면 [Action, Adventure, Fantasy, Science Fiction], [Adventure, Fantasy, Action] 등으로 구성되어 있습니다.

장르별 유사도 측정 방법은 genres를 문자열로 변경한 뒤 CountVectorizer로 피처 벡터화한 행렬 데이터 값을 코사인 유사도로 비교합니다.

 

   CountVectorizer
      단어 들의 카운트(출현 빈도(frequency))로 여러 문서들을 벡터화.
      카운트 행렬, 단어 문서 행렬 (Term-Document Matrix, TDM))
      모두 소문자로 변환시키기 때문에 me 와 Me 는 모두 같은 특성이 된다.

 

 

 

 

# CountVectorizer를 적용하기 위해 공백문자로 word 단위가 구분되는 문자열로 변환.
movies_df['genres_literal']=movies_df['genres'].apply(lambda x : (' ').join(x))

# min_df는 너무 드물게로 나타나는 용어를 제거하는 데 사용. min_df = 0.01은 "문서의 1 % 미만"에 나타나는 용어를 무시한다. 
# ngram_range는 n-그램 범위.
count_vect=CountVectorizer(min_df=0, ngram_range=(1, 2))
genre_mat=count_vect.fit_transform(movies_df['genres_literal'])
print(genre_mat.shape)

결과

 

유사도 측정 순서

   

   ① 리스트 객체로 구성된 genres 컬럼을 apply(lambda x: (' ').join(x))를 이용해 공백문자로 구분되는 문자열로 변환하여 별도의 컬럼인 genres_literal 컬럼으로 저장.

   ② CountVectorizer를 이용하여 Count 기반으로 피처 벡터 행렬로 변환.

   ③ 피처 벡터화 행렬로 변환한 데이터 세트를 코사인 유사도를 통해 비교하여 유사도가 높은 영화 중 평점이 높은 순으로 추천.

 

 

 

 

피처 벡터 행렬을 코사인 유사도로 비교해야 하는데 사이킷런의 cosine_similarity()를 이용하여 측정할 수 있습니다.

cosine_similarity() 함수는 아래 그림과 같이 코사인 유사도를 행렬 행태로 변환하는 함수입니다.

 

 

genre_sim=cosine_similarity(genre_mat, genre_mat)
print(genre_sim.shape)
print(genre_sim[:1])

결과

movies_df의 genres 컬럼을 문자열 변환하고 피처 벡터화한 행렬 genre_mat를 cosine_similarities()로 계산된 genre_sim 객체는 movies_df DataFrame의 행별 장르 유사도 값을 가지고 있습니다.

 

 

# [:, ::-1] axis = 1 기준으로 2차원 numpy 배열 뒤집기
genre_sim_sorted_ind=genre_sim.argsort()[:, ::-1]
print(genre_sim_sorted_ind[:1])

결과

코사인 유사도가 측정된 genre_sim 객체를 Numpy의 argsort() 함수를 통해 유사도가 높은 순 정렬합니다.

또한, 각 레코드에서 맨 뒤에 있는 인덱스 값을 비교 행 위치로 뒤집어 줍니다.

 

결과 값을 보자면 첫 번째(0번) 레코드 의 경우 자기 자신 0번을 제외하면 3494번 레코드가 가장 유사도가 높고, 그 다음이 813번 레코드, 가장 유사도가 낮은 레코드는 2401번 레코드라는 뜻입니다.

 

 

 

장르 콘텐츠 필터링 영화 추천

 

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

결과

데이터 프레임을 확인해보면 쇼생크 탈출('The Shawshank Redemption'), 대부('The Godfather')와 같이 평가 수도 많고 평점이 높은 유명한 영화도 있으나 평가 수가 1개나 2개인 데이터의 평점이 상위권에 있는 것을 볼 수 있습니다.

이러한 데이터를 실제로 추천하면 왜곡된 추천이므로 유명한 영화 평점 사이트인 IMDB에서는 평가 횟수에 대한 가중치가 부여된 평점(Weighted Rating)방식을 사용합니다.


가중 평점(Weighted Rating) = (v/(v+m)) * R + (m/(v+m)) * C

 

각 변수의 의미

 

      ⊙ v : 영화에 평가를 매긴 횟수(movie_df의 'vote_count')

      m : 평점을 부여하기 위한 최소 평가 수(movies_df['vote_count'].quantile(0.6) - 전체 투표 수에서 상위 60%의 횟수를 기준)

      R : 영화의 평균 평점(movie_df의 'vote_average')

      C : 전체 영화의 평균 평점(movie_df['vote_average'].mean())

 

 

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.apply(weighted_vote_average, axis=1)
movies_df[['title', 'weighted_vote', 'vote_count']].sort_values('weighted_vote', ascending=False)[:10]

결과

새롭게 부여된 가중 평점('weighted_vote') 컬럼을 기준으로 정렬한 결과 좀 더 정확한 결과가 도출되었습니다.

이제 가중 평점을 토대로 인자 값 DataFrame, 장르 코사인 유사도 인덱스를 가지고 있는 객체, 추천 대상의 기준이 되는 아이템 이름(영화 제목), 추천할 영화 건수를 입력하면 추천 영화 정보를 가지는 DataFrame을 반환하는 함수를 만들어보겠습니다.

 

 

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)]
  # reshape(-1) 1차열 배열 반환
  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, 'No Country for Old Men', 10)
similar_movies[['title', 'vote_count', 'weighted_vote']]

결과

'노인을 위한 나라는 없다'('No Country for Old Men')을 기준으로 추천해보면 1순위 추천 영화 '센과 치히로의 행방불명'('Spirited Away'), 2순위 추천 영화 '백 투더 퓨쳐'('Back to the Future')가 나왔습니다.

'노인을 위한 나라는 없다'는 범죄, 드라마, 스릴러 장르인데 '센과 치히로의 행방불명'은 판타지, 어드벤쳐, 애니메이션 장르이므로 다소 맞지 않는 결과라고 볼 수 있습니다.

장르만으로는 영화가 가지고 있는 많은 요소와 개인의 성향을 반영하기에는 다소 어려울 수 있습니다.

 

 

 

반응형
LIST
Comments