[Python] 머신러닝 완벽가이드 - 09. 추천 시스템[최근접 이웃]

Updated:

파이썬 머신러닝 완벽가이드 교재를 토대로 공부한 내용입니다.

실습과정에서 필요에 따라 내용의 누락 및 추가, 수정사항이 있습니다.


기본 세팅

import numpy as np
import pandas as pd

import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns

import warnings
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

mpl.rc('font', family='NanumGothic') # 폰트 설정
mpl.rc('axes', unicode_minus=False) # 유니코드에서 음수 부호 설정

# 차트 스타일 설정
sns.set(font="NanumGothic", rc={"axes.unicode_minus":False}, style='darkgrid')
plt.rc("figure", figsize=(10,8))

warnings.filterwarnings("ignore")

협업 필터링

추천 시스템은 크게 콘텐츠 기반 필터링 방식과 협업 필터링 방식으로 나뉜다.

그리고 협업 필터링 방식은 다시 최근접 이웃 협업 필터링잠재 요인 협업 필터링으로 나뉜다.

협업 필터링은 사용자가 매긴 평점이나 상품 구매 이력 등 사용자 행동 양식만을 기반으로 추천하는 방식이다.

예를 들어, 나와 취향이 비슷한 친구에게 다른 영화의 정보를 물어보는 것과 유사한 방식이다.

2. 최근접 이웃 협업 필터링

최근접 이웃 협업 필터링은 사용자 기반과 아이템 기반으로 분류한다.

  • 사용자 기반: 나와 비슷한 성향의 사람이 재밌게 본 영화 등을 추천하는 방식이다.

  • 아이템 기반: 아이템에 대한 평가가 유사한 아이템을 추천하는 방식으로 일반적으로 성능이 더 뛰어나다.

여기선 아이템 기반의 협업 필터링을 구현해본다.

아이템 기반이 아이템의 속성이 비슷한 것이 아닌 평가가 유사한 것을 추천하는 방식임을 유의하자.

2.1 데이터 로딩 및 가공

데이터는 Grouplens 사이트에서 만든 MovieLens 데이터를 사용한다.

movies = pd.read_csv("./ml-latest-small/movies.csv")
ratings = pd.read_csv("./ml-latest-small/ratings.csv")
movies.head(1)
movieId title genres
0 1 Toy Story (1995) Adventure|Animation|Children|Comedy|Fantasy
  • moives 파일은 영화의 제목과 장르 정보를 가지고 있다.
ratings.head(1)
userId movieId rating timestamp
0 1 1 4.0 964982703
  • ratings 파일은 사용자별로 영화에 대한 평점 정보를 가지고 있다.

  • 평점은 0.5 ~ 5점 사이로, 0.5점 단위로 평점이 부여된다.

ratings_matrix = ratings.pivot_table("rating", "userId", "movieId")
ratings_matrix.head(1)
movieId 1 2 3 4 5 6 7 8 9 10 ... 193565 193567 193571 193573 193579 193581 193583 193585 193587 193609
userId
1 4.0 NaN 4.0 NaN NaN 4.0 NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

1 rows × 9724 columns

  • 사용자-아이템 평점 행렬을 생성하였다.

  • 사용자가 평점을 매기지 않은 영화는 모두 NaN값으로 할당되었는데 이는 0점으로 변경한다.

  • 컬럼이 movieId여서 무슨 영화인지 직관적으로 알아보기 힘들어 title로 변경하도록 한다.

# ratings 데이터와 movies 데이터 결합
rating_movies = pd.merge(ratings, movies, on="movieId")

# 사용자-아이템 평점 행렬 생성
ratings_matrix = rating_movies.pivot_table("rating", "userId", "title")

# NaN값은 0으로 변환
ratings_matrix.fillna(0, inplace=True)

# 아이템-사용자 평점 행렬로 전치
ratings_matrix_T = ratings_matrix.T
ratings_matrix_T.head(3)
userId 1 2 3 4 5 6 7 8 9 10 ... 601 602 603 604 605 606 607 608 609 610
title
'71 (2014) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 4.0
'Hellboy': The Seeds of Creation (2004) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
'Round Midnight (1986) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

3 rows × 610 columns

  • 컬럼은 영화 제목으로 변경하고 NaN값은 모두 0으로 변환하였다.

  • 아이템 기반 협업 필터링을 위해 사용자-아이템 평점 행렬을 전치하여 아이템-사용자 평점 행렬로 생성하였다.

2.2 영화 간 유사도 산출

from sklearn.metrics.pairwise import cosine_similarity

# 아이템 유사도 행렬
item_sim = cosine_similarity(ratings_matrix_T, ratings_matrix_T)

# 데이터 프레임 형태로 저장
item_sim_df = pd.DataFrame(item_sim, index=ratings_matrix_T.index, columns=ratings_matrix_T.index)

# item_sim_df.shape: 9719 x 9719
item_sim_df.iloc[:4,:4]
title '71 (2014) 'Hellboy': The Seeds of Creation (2004) 'Round Midnight (1986) 'Salem's Lot (2004)
title
'71 (2014) 1.0 0.000000 0.000000 0.0
'Hellboy': The Seeds of Creation (2004) 0.0 1.000000 0.707107 0.0
'Round Midnight (1986) 0.0 0.707107 1.000000 0.0
'Salem's Lot (2004) 0.0 0.000000 0.000000 1.0
  • 각 영화별 유사도 행렬을 데이터 프레임으로 생성하였다.
# 대부와 유사도가 높은 상위 5개 영화
item_sim_df["Godfather, The (1972)"].sort_values(ascending=False)[1:6]
title
Godfather: Part II, The (1974)               0.821773
Goodfellas (1990)                            0.664841
One Flew Over the Cuckoo's Nest (1975)       0.620536
Star Wars: Episode IV - A New Hope (1977)    0.595317
Fargo (1996)                                 0.588614
Name: Godfather, The (1972), dtype: float64
  • 영화 대부와는 대부2편이 가장 유사도가 높고 뒤이어 유사한 영화가 나타난다.

  • 스타워즈와 같이 장르가 완전 다른 영화도 유사도가 높게 나타났다.

  • 콘텐츠 기반에선 장르별 유사도를 사용하였으나 여기선 평점에 따른 유사도이기 때문이다.

  • 다시 한번 아이템의 속성이 아닌 평가가 비슷한 것을 추천함을 유의하자.

2.3 영화 추천

앞서 생성한 영화 유사도 데이터로 사람들에게 영화 추천이 가능할 것이다.

하지만 이는 개인의 취향은 고려하지 않고 영화의 유사도만을 가지고 추천하는 것이다.

이제 최근접 이웃 협업 필터링으로 개인에게 최적화된 영화 추천을 구현해보자.

아이템 기반의 협업 필터링에서 개인화된 예측 평점은 다음과 같은 식으로 구할 수 있다.

\[\hat{R}_{u, i} = \sum^N \left(S_{i, N} \cdot R_{u, N} \right) \big/ \; \sum^N \vert S_{i, N} \vert\]
  • $\hat{R}_{u, i}$: 사용자 u, 아이템 i의 개인화된 예측 평점 값

  • $S_{i, N}$: 아이템 i와 가장 유사도가 높은 Top-N개 아이템의 유사도 벡터

  • $R_{u, N}$: 사용자 u의 아이템 i와 가장 유사도가 높은 Top-N개 아이템에 대한 실제 평점 벡터

  • $N$: 아이템의 최근접 이웃 범위 계수로 유사도가 가장 높은 Top-N개의 아이템을 추출하는데 사용

사용자별 예측 평점 함수

# 인수로 사용자-아이템 평점 행렬(NaN은 현재 0으로 대체), 아이템 유사도 행렬 사용
def predict_rating(ratings_arr, item_sim_arr):
    # ratings_arr: u x i, item_sim_arr: i x i
    sum_sr = ratings_arr @ item_sim_arr
    sum_s_abs = np.array( [ np.abs(item_sim_arr).sum(axis=1) ] )
    
    ratings_pred =  sum_sr / sum_s_abs
    
    return ratings_pred
  • 사용자별 예측 평점 함수를 생성하였다.

  • 우선 Top-N은 고려하지 않고 전체로 구현하였다.

# 사용자별 예측 평점
ratings_pred = predict_rating(ratings_matrix.values , item_sim_df.values)

ratings_pred_matrix = pd.DataFrame(data=ratings_pred, index= ratings_matrix.index,
                                   columns = ratings_matrix.columns)
ratings_pred_matrix.head(3)
title '71 (2014) 'Hellboy': The Seeds of Creation (2004) 'Round Midnight (1986) 'Salem's Lot (2004) 'Til There Was You (1997) 'Tis the Season for Love (2015) 'burbs, The (1989) 'night Mother (1986) (500) Days of Summer (2009) *batteries not included (1987) ... Zulu (2013) [REC] (2007) [REC]² (2009) [REC]³ 3 Génesis (2012) anohana: The Flower We Saw That Day - The Movie (2013) eXistenZ (1999) xXx (2002) xXx: State of the Union (2005) ¡Three Amigos! (1986) À nous la liberté (Freedom for Us) (1931)
userId
1 0.070345 0.577855 0.321696 0.227055 0.206958 0.194615 0.249883 0.102542 0.157084 0.178197 ... 0.113608 0.181738 0.133962 0.128574 0.006179 0.212070 0.192921 0.136024 0.292955 0.720347
2 0.018260 0.042744 0.018861 0.000000 0.000000 0.035995 0.013413 0.002314 0.032213 0.014863 ... 0.015640 0.020855 0.020119 0.015745 0.049983 0.014876 0.021616 0.024528 0.017563 0.000000
3 0.011884 0.030279 0.064437 0.003762 0.003749 0.002722 0.014625 0.002085 0.005666 0.006272 ... 0.006923 0.011665 0.011800 0.012225 0.000000 0.008194 0.007017 0.009229 0.010420 0.084501

3 rows × 9719 columns

  • 실제 평점이 없던 NaN값에 예측 평점이 부여되었다.

  • 예측 평점이 실제 평점에 비해 작을 수 있는데 이는 내적 결과를 유사도 벡터 합으로 나누어서이다.

예측 성능 평가 함수

from sklearn.metrics import mean_squared_error

# 성능 평가는 MSE를 사용
def get_mse(pred, actual):
    # 평점이 있는 실제 영화만 추출 (1차원 배열로 변환)
    pred = pred[actual.nonzero()].flatten()
    actual = actual[actual.nonzero()].flatten()
    
    return mean_squared_error(pred, actual)
  • 앞서 영화 평점이 없는 NaN값을 모두 0으로 변환하였다.

  • 성능 평가에선 실제 평점이 있는 영화로만 계산하기 위해 평점이 0인 경우 제외하였다.

  • 배열의 nonzero()를 이용해서 제외

MSE1 = get_mse(ratings_pred, ratings_matrix.values)
print(f'아이템 기반 모든 인접 이웃 MSE: {MSE1:.4f}')
아이템 기반 모든 인접 이웃 MSE: 9.8954
  • MSE는 약 9.8594로 나타났다.

  • 현재는 영화의 유사도 벡터 전체를 사용하여 상대적으로 평점 예측이 떨어졌다.

  • Top-N을 사용하여 특정 영화와 가장 비슷한 유사도를 가지는 영화에 대해서만 유사도 벡터를 적용해보자.

def predict_rating_topsim(ratings_arr, item_sim_arr, N=20):
    # 사용자-아이템 평점 행렬 크기만큼 0으로 채운 예측 행렬 초기화
    pred = np.zeros(ratings_arr.shape)

    # 사용자-아이템 평점 행렬의 열 크기(아이템 수)만큼 반복 (row: 사용자, col: 아이템)
    for col in range(ratings_arr.shape[1]):
                
        # 특정 아이템의 유사도 행렬 오름차순 정렬시 index .. (1)
        temp = np.argsort(item_sim_arr[:, col]) 
        
        # (1)의 index를 역순으로 나열시 상위 N개의 index = 특정 아이템의 유사도 상위 N개 아이템 index .. (2)
        top_n_items = [ temp[:-1-N:-1] ]
        
        # 개인화된 예측 평점을 계산: 반복당 특정 아이템의 예측 평점(사용자 전체)
        for row in range(ratings_arr.shape[0]):
            
            # (2)의 유사도 행렬
            item_sim_arr_topN = item_sim_arr[col, :][top_n_items].T # N x 1
            
            # (2)의 실제 평점 행렬
            ratings_arr_topN = ratings_arr[row, :][top_n_items]     # 1 x N
            
            # 예측 평점
            pred[row, col] = ratings_arr_topN @ item_sim_arr_topN
            pred[row, col] /= np.sum( np.abs(item_sim_arr_topN) )
            
    return pred
  • 특정 아이템과 유사도가 높은 상위 N개 아이템과의 유사도 벡터를 구한다.

  • 사용자의 평점 중 앞서 구한 상위 N개 아이템에 대한 평점만 사용한다.

  • 두 행렬을 곱하여서 사용자 예측 평점을 계산한다.

  • 이 함수는 행, 열별로 반복을 수행하여서 데이터 크기가 크면 수행시간이 오래 걸린다.

# 사용자별 예측 평점
ratings_pred = predict_rating_topsim(ratings_matrix.values , item_sim_df.values, N=20)

# 성능 평가
MSE2 = get_mse(ratings_pred, ratings_matrix.values )
print(f'아이템 기반 인접 TOP-20 이웃 MSE: {MSE2:.4f}')

# 예측 평점 데이터 프레임
ratings_pred_matrix = pd.DataFrame(data=ratings_pred, index= ratings_matrix.index,
                                   columns = ratings_matrix.columns)
아이템 기반 인접 TOP-20 이웃 MSE: 3.6950
  • MSE가 기존 9.8594에서 3.6950으로 많이 향상되었다.
# userId 9가 높은 평점을 준 영화 (실제 평점)
user_rating_id = ratings_matrix.loc[9, :]
user_rating_id[ user_rating_id > 0].sort_values(ascending=False)[:10]
title
Adaptation (2002)                                                                 5.0
Austin Powers in Goldmember (2002)                                                5.0
Lord of the Rings: The Fellowship of the Ring, The (2001)                         5.0
Lord of the Rings: The Two Towers, The (2002)                                     5.0
Producers, The (1968)                                                             5.0
Citizen Kane (1941)                                                               5.0
Raiders of the Lost Ark (Indiana Jones and the Raiders of the Lost Ark) (1981)    5.0
Back to the Future (1985)                                                         5.0
Glengarry Glen Ross (1992)                                                        4.0
Sunset Blvd. (a.k.a. Sunset Boulevard) (1950)                                     4.0
Name: 9, dtype: float64
  • userId 9번이 높은 평점을 준 상위 10개의 영화로 반지의 제왕 등이 눈에 띈다.

  • 앞서 만든 예측 평점 함수를 이용해서 아직 평점을 주지 않은 영화를 추천해보자.

# 아직 보지 않은 영화 리스트 함수
def get_unseen_movies(ratings_matrix, userId):
    
    # user_rating: userId의 아이템 평점 정보 (시리즈 형태: title을 index로 가진다.)
    user_rating = ratings_matrix.loc[userId,:]
    
    # user_rating=0인 아직 안본 영화
    unseen_movie_list = user_rating[ user_rating == 0].index.tolist()
    
    # 모든 영화명을 list 객체로 만듬. 
    movies_list = ratings_matrix.columns.tolist()
    
    # 한줄 for + if문으로 안본 영화 리스트 생성
    unseen_list = [ movie for movie in movies_list if movie in unseen_movie_list]
    
    return unseen_list


# 보지 않은 영화 중 예측 높은 순서로 시리즈 반환
def recomm_movie_by_userid(pred_df, userId, unseen_list, top_n=10):    
    recomm_movies = pred_df.loc[userId, unseen_list].sort_values(ascending=False)[:top_n]
    
    return recomm_movies
# 아직 보지 않은 영화 리스트
unseen_list = get_unseen_movies(ratings_matrix, 9)

# 아이템 기반의 최근접 이웃 협업 필터링으로 영화 추천
recomm_movies = recomm_movie_by_userid(ratings_pred_matrix, 9, unseen_list, top_n=10)

# 데이터 프레임 생성
recomm_movies = pd.DataFrame(data=recomm_movies.values, index=recomm_movies.index, columns=['pred_score'])
recomm_movies
pred_score
title
Shrek (2001) 0.866202
Spider-Man (2002) 0.857854
Last Samurai, The (2003) 0.817473
Indiana Jones and the Temple of Doom (1984) 0.816626
Matrix Reloaded, The (2003) 0.800990
Harry Potter and the Sorcerer's Stone (a.k.a. Harry Potter and the Philosopher's Stone) (2001) 0.765159
Gladiator (2000) 0.740956
Matrix, The (1999) 0.732693
Pirates of the Caribbean: The Curse of the Black Pearl (2003) 0.689591
Lord of the Rings: The Return of the King, The (2003) 0.676711
  • userId 9번이 아직 보지 않은 영화 중 예측 평점이 가장 높은 상위 10개 영화를 출력했다.

  • 슈렉, 스파이더맨 등 흥행성이 좋은 영화가 추천되었다.

Leave a comment