그래프를 추천시스템에 어떻게 활용할까 (심화)

그래프를 추천시스템에 어떻게 활용할까? (심화)


✅ 추천시스템 기본 복습



✅ 넷플릭스 챌린지 소개

1️⃣ 넷플릭스 챌린지 데이터셋

  • 넷플릭스 챌린지(Netflix Challenge)에서는 사용자별 영화 평점 데이터가 사용되었다.
  • 훈련 데이터(Training Data)는 2000년부터 2005년까지 수집한 48만명 사용자1만 8천개의 영화에 대한 1억 개의 평점으로 구성되어 있다.
  • 평가 데이터(Test Data)는 각 사용자의 최신 평점 280만개로 구성되어 있다.

image.png


2️⃣ 넷플릭스 챌린지 대회 소개

📌 넷플릭스 챌린지의 목표는 추천시스템의 성능을 10%이상 향상시키는 것이다.

  • 평균 제곱근 오차 0.9514을 0.8563까지 낮출 경우 100만불의 상금을 받는 조건이었다.
  • 2006년부터 2009년까지 진행되었으며, 2700개의 팀이 참여하였다.
  • 넷플릭스 챌린지를 통해 추천시스템의 성능이 비약적으로 발전

image.png



✅ 잠재 인수 모형

1️⃣ 잠재 인수 모형 개요

📌 잠재 인수 모형(Latent Factor Model)의 핵심은 사용자와 상품을 벡터로 표현하는 것이다.

image.png


📌 사용자와 영화를 임베딩한 예시이다.

image.png


📌 잠재 인수 모형에서는 고정된 인수 대신 효과적인 인수를 학습하는 것을 목표로 한다.

  • 학습한 인수를 잠재 인수(Latent Factor)라 부른다.

image.png


2️⃣ 손실 함수

📌 사용자와 상품을 임베딩하는 기준은 무엇인가요?

  • 사용자와 상품의 임베딩의 내적(Inner Product)이 평점과 최대한 유사하도록 하는 것
  • 사용자 𝑥의 임베딩을 $p_{x}$, 상품 𝑖의 임베딩을 $q_{i}$라고 하자
  • 사용자 𝑥의 상품 𝑖에 대한 평점을 $r_{xi}$라고 하자
  • 임베딩의 목표는 $p_{x}^{T}q_{i}$이 $r_{xi}$와 유사하도록 하는 것이다.


📌 행렬 차원에서 살펴보자

  • 사용자 수의 열과 상품 수의 행을 가진 평점 행렬을 𝑅이라고 하자
  • 사용자들의 임베딩, 즉 벡터를 쌓아서 만든 사용자 행렬을 𝑃라고 하자
  • 영화들의 임베딩, 즉 벡터를 쌓아서 만든 상품 행렬을 𝑄라고 하자

image.png


📌 잠재 인수 모형은 다음 손실 함수를 최소화하는 $P$와 $Q$를 찾는 것이 목표이다.

image.png

하지만, 위 손실 함수를 사용할 경우 과적합(Overfitting)이 발생할 수 있다. 과적합이란 기계학습 모형이 훈련 데이터의 잡음(Noise)까지 학습하여, 평가 성능은 오히려 감소하는 현상을 의미한다.


📌 과적합을 방지하기 위하여 정규화 항을 손실 함수에 더해준다.

image.png


정규화는 극단적인, 즉 절댓값이 너무 큰 임베딩을 방지하는 효과가 있다.

image.png


3️⃣ 최적화

📌 손실함수를 최소화하는 $P$와 $Q$를 찾기 위해서는 (확률적) 경사하강법을 사용한다.

  • 경사하강법은 손실함수를 안정적으로 하지만 느리게 감소시킨다.
  • 확률적 경사하강법은 손실함수를 불안정하지만 빠르게 감소시킨다.
  • 실제로는 확률적 경사하강법이 더 많이 사용된다.

image.png

🔎 넷플릭스 챌린지에서 결과를 확인해 보면 아래와 같이 감소한 것을 볼 수 있다.

image.png



✅ 고급 잠재 인수 모형

1️⃣ 사용자와 상품의 편향을 고려한 잠재 인수 모형

📌 각 사용자의 편향은 해당 사용자의 평점 평균과 전체 평점 평균의 차이다.

  • 나연이 매긴 평점의 평균이 4.0개의 별,
  • 다현이 매긴 평점의 평균이 3.5개의 별이라고 하자.

전체 평점 평균이 3.7개의 별인 경우,

  • 나연의 사용자 편향은 4.0 - 3.7 = 0.3개의 별이고
  • 다현의 사용자 편향은 3.5 - 3.7 = -0.2개의 별이다.

📌 각 상품의 편향은 해당 상품에 대한 평점 평균과 전체 평점 평균의 차이다.

  • 영화 식스센스에 대한 평점의 평균이 4.5개의 별,
  • 영화 클레멘타인이 매긴 평점의 평균이 3.0개의 별이라고 하자.

전체 평점 평균이 3.7개의 별인 경우,

  • 식스센스의 상품 편향은 4.5 – 3.7 = 0.8개의 별이고
  • 클레멘타인의 상품 편향은 3.0 - 3.7 = -0.7개의 별이다.


📌개선된 잠재 인수 모형에서는 평점을 전체 평균, 사용자 편향, 상품 편향, 상호작용으로 분리한다.

image.png

따라서 개선된 잠재 인수 모형의 손실 함수는 아래와 같다.

image.png

🔎 사용자, 상품의 편향을 고려한 결과를 확인해 보면 아래와 같다.

image.png


2️⃣ 시간적 편향을 고려한 잠재 인수 모형

📌넷플릭스 시스템의 변화로 평균 평점이 크게 상승하는 사건이 있었다.

image.png

📌영화의 평점은 출시일 이후 시간이 지남에 따라 상승하는 경향을 갖는다.

image.png


📌 개선된 잠재 인수 모형에서는 이러한 시간적 편향을 고려한다.

  • 구체적으로 사용자 편향과 상품 편향을 시간에 따른 함수로 가정한다.

image.png

🔎 시간적 편향을 고려한 결과를 확인해 보면 아래와 같다.

image.png



✅ 넷플릭스 챌린지의 결과

1️⃣ 앙상블 학습

📌BellKor 팀은 앙상블 학습을 사용하여 처음으로 목표 성능에 도달

image.png



✅ Surprise 라이브러리와 잠재 인수모형의 활용

라이브러리 로드

In [1]:
import numpy as np 
import pandas as pd
from surprise import SVD
from surprise.model_selection import train_test_split
from surprise.dataset import DatasetAutoFolds
from surprise.model_selection import cross_validate
from surprise import Dataset, Reader
from surprise import accuracy

데이터 로드(100,000개의 평점으로 구성)

In [2]:
#### 데이터셋 불러오기(MovieLens 10k) ####
df_ratings = pd.read_csv('../실습코드/data/others/ratings.csv')

#### 평점 데이터셋 형태 확인#### 
# surprise library의 Reader 사용 시 반드시 사용자-아이템-평점 순으로 정보가 들어가 있어야 함
print("### Rating Dataset Format ###", end='\n\n')
display(df_ratings.head())
df_ratings.drop(['timestamp'], axis=1, inplace=True)
print("### Rating Dataset - Timestamp Removed ###", end='\n\n')
display(df_ratings)
### Rating Dataset Format ###

userId movieId rating timestamp
0 1 1 4.0 964982703
1 1 3 4.0 964981247
2 1 6 4.0 964982224
3 1 47 5.0 964983815
4 1 50 5.0 964982931
### Rating Dataset - Timestamp Removed ###

userId movieId rating
0 1 1 4.0
1 1 3 4.0
2 1 6 4.0
3 1 47 5.0
4 1 50 5.0
... ... ... ...
100831 610 166534 4.0
100832 610 168248 5.0
100833 610 168250 5.0
100834 610 168252 5.0
100835 610 170875 3.0

100836 rows × 3 columns

In [3]:
df_movies = pd.read_csv('../실습코드/data/others/movies.csv')

#### 영화 데이터셋 형태 확인 ####
print("### Movie Dataset Format ###", end = '\n\n')
print("Columns of Movie Dataset : ",df_movies.columns, end = '\n\n')
display(df_movies.head())
### Movie Dataset Format ###

Columns of Movie Dataset :  Index(['movieId', 'title', 'genres'], dtype='object')

movieId title genres
0 1 Toy Story (1995) Adventure|Animation|Children|Comedy|Fantasy
1 2 Jumanji (1995) Adventure|Children|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama|Romance
4 5 Father of the Bride Part II (1995) Comedy
In [4]:
#### Dataset의 User, Movie 수 확인 ####
n_users = df_ratings.userId.unique().shape[0]
n_items = df_ratings.movieId.unique().shape[0]
print("num users: {}, num items:{}".format(n_users, n_items))
num users: 610, num items:9724
In [5]:
### Add Your Own Data ### 

###################################### Example 1#################################################
# User 800 is a HUGE fan of Musical Movies
rows = []                               # row = [user_id, movie_id, rating]
user_id = 800
rows.append([user_id, 73, 5])        # movie    73: Miserables, Les (1995)
rows.append([user_id, 107780, 5])     # movie  107780: Cats(1998) 
rows.append([user_id, 588, 5])     # movie  588: Aladin(1992)
rows.append([user_id, 60397, 5])    # movie 69397: Mamma Mia!(2008)
rows.append([user_id, 99149, 5])    # movie 99149: Miserables, Les (2012)
rows.append([user_id, 138186, 1])    # movie 138186: Sorrow(2015)
rows.append([user_id, 1997, 1])    # movie 1997: Scream 2 (1991)

##################################################################################################

###################################### Example 2#################################################
# User 900 is a HUGE fan of Animation Movies
rows = []                               # row = [user_id, movie_id, rating]
user_id = 900
rows.append([user_id, 1022, 5])        # movie    1022: Cinderella(1950)
rows.append([user_id, 594, 5])     # movie  594: Snow White and the Seven Dwarfs(1937) 
rows.append([user_id, 106696, 5])     # movie  106696: Frozen(2013)
rows.append([user_id, 166461, 5])    # movie 166461: Moana(2016)
rows.append([user_id, 595, 5])    # movie 595: Beauty and the Beast (1991)
rows.append([user_id, 138168, 1])    # movie 138168: Sorrow(2015)  위 데이터에 없는 영화
rows.append([user_id, 1997, 1])    # movie 1997: Scream 2 (1991)

##################################################################################################


########################### Add Your Own Ratings using 'movie.csv' data #########################
# my_rows = []
# my_id = 2021
# rows.append([user_id, ,])       # Fill your movie id and rating     
# rows.append([user_id, ,])       # 자신이 평가할 영화의 id와 점수를 입력한다..
# rows.append([user_id, ,])
# rows.append([user_id, ,])
# rows.append([user_id, ,])

##################################################################################################
for row in rows:
    df_ratings = df_ratings.append(pd.Series(row, index=df_ratings.columns), ignore_index=True)
print(df_ratings)
        userId  movieId  rating
0            1        1     4.0
1            1        3     4.0
2            1        6     4.0
3            1       47     5.0
4            1       50     5.0
...        ...      ...     ...
100838     900   106696     5.0
100839     900   166461     5.0
100840     900      595     5.0
100841     900   138168     1.0
100842     900     1997     1.0

[100843 rows x 3 columns]
In [6]:
#### Dataset의 User, Movie 수 확인 ####
n_users = df_ratings.userId.unique().shape[0]
n_items = df_ratings.movieId.unique().shape[0]
print("num users: {}, num items:{}".format(n_users, n_items))
num users: 611, num items:9725

훈련 데이터와 평가 데이터를 분리

In [7]:
#### pandas dataframe을 surprise dataset 형태로 바꿔준 후, train set과 test set을 split 해준다 ####
reader = Reader(rating_scale=(0, 5))
data = Dataset.load_from_df(df_ratings[['userId','movieId','rating']], reader=reader)

train, test = train_test_split(data, test_size=0.2, shuffle=True)

print(type(data))
print(type(train))
<class 'surprise.dataset.DatasetAutoFolds'>
<class 'surprise.trainset.Trainset'>
In [8]:
## Grid Search를 위해 surprise.trainset 형태의 데이터를 surprise.dataset으로 변경해준다
iterator = train.all_ratings()
train_df = pd.DataFrame(columns=['userId', 'movieId', 'rating'])
i = 0
for (uid, iid, rating) in iterator:
    train_df.loc[i] = [train.to_raw_uid(int(uid)), train.to_raw_iid(iid), rating]
    i = i+1

train_data = Dataset.load_from_df(train_df, reader=reader)

print(type(train))
print(type(train_data))
<class 'surprise.trainset.Trainset'>
<class 'surprise.dataset.DatasetAutoFolds'>

하이퍼파라미터를 탐색한다.

In [9]:
### Hyperparameter Grid Search ### 
from surprise.model_selection import GridSearchCV
param_grid = {'n_factors': [10,15,20,30,50,100]}

grid = GridSearchCV(SVD, param_grid, measures = ['rmse', 'mae'], cv=4)
grid.fit(train_data)

print(grid.best_score['rmse'])
print(grid.best_params['rmse'])
0.8783606583322707
{'n_factors': 10}
  • n_factors: 임베딩 공간의 차원을 설정해준다.

잠재 인수 모형 학습

In [10]:
### Use the Hyperparameter with best performance ###
print(grid.best_params)
algorithm = SVD(grid.best_params['rmse']['n_factors'])
algorithm.fit(train)
{'rmse': {'n_factors': 10}, 'mae': {'n_factors': 10}}
Out[10]:
<surprise.prediction_algorithms.matrix_factorization.SVD at 0x21d8e64cfc8>

모델 예측

In [11]:
##### algorithm prediction #####
prediction = algorithm.test(test)
for p in prediction[:5]:            # prediction 결과값 5개 미리보기
    print(p)                        # r_ui : 실제 rating 값, est: 예측된 rating 값
user: 475        item: 87232      r_ui = 3.50   est = 4.60   {'was_impossible': False}
user: 47         item: 7160       r_ui = 3.00   est = 3.11   {'was_impossible': False}
user: 414        item: 1805       r_ui = 4.00   est = 2.79   {'was_impossible': False}
user: 119        item: 131714     r_ui = 4.00   est = 3.89   {'was_impossible': False}
user: 549        item: 2571       r_ui = 5.00   est = 4.03   {'was_impossible': False}
In [12]:
#### 특정 user, 특정 item에 대한 prediction 값 ###
uid = 800
iid = 8368
prediction_user_item = algorithm.predict(uid, iid)
print(prediction_user_item)   
user: 800        item: 8368       r_ui = None   est = 4.08   {'was_impossible': False}

영화 ID로 부터 영화 제목을 얻기 위한 변수 설정

In [13]:
#### Get Movid Name from Movie ID - 영화 ID로부터 영화 제목 얻기 ###
movie_set = set()     
ratings = np.zeros((n_users, n_items))
for (_, movie_id, _) in df_ratings.itertuples(index=False):
    movie_set.add(movie_id)

movie_id_to_name=dict()
movie_id_to_genre=dict()

for (movie_id, movie_name, movie_genre) in df_movies.itertuples(index=False):
    if movie_id not in movie_set:              # 어떤 영화가 rating data에 없는 경우 skip
        continue
    movie_id_to_name[movie_id] = movie_name 
    movie_id_to_genre[movie_id] = movie_genre
In [14]:
#해당 user가 아직 보지 않은 영화를 return해주는 함수
def get_unseen_movies(data, user_id):

    watched_movies = set()
    total_movies = set()
    for (uid, iid, rating) in data.all_ratings():
        
        total_movies.add(iid)
        if uid == user_id:
            watched_movies.add(iid)
    
    unseen_movies = total_movies - watched_movies
    return unseen_movies
    # return total_movies
In [15]:
# 특정 user에게 top k개의 영상을 추천해주는 함수
def recommend(train, algorithm, user_id, top_k=10):
    unseen_movies = get_unseen_movies(train, user_id)
    prediction = [algorithm.predict(user_id, movie_id) for movie_id in unseen_movies]
    prediction.sort(key=lambda x:x.est, reverse=True)  

    for _, movie, _, pred, _ in prediction[:top_k]:
        print("movid id: {}, movie genre: {},predicted rating: {}".format(movie_id_to_name[movie], movie_id_to_genre[movie], pred))
In [16]:
# 800번 유저 추천 결과
recommend(train, algorithm, user_id=800, top_k=10)
movid id: Shawshank Redemption, The (1994), movie genre: Crime|Drama,predicted rating: 4.389791615923516
movid id: Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1964), movie genre: Comedy|War,predicted rating: 4.37331233374186
movid id: Lawrence of Arabia (1962), movie genre: Adventure|Drama|War,predicted rating: 4.371576287772258
movid id: Cool Hand Luke (1967), movie genre: Drama,predicted rating: 4.359722965134315
movid id: Casablanca (1942), movie genre: Drama|Romance,predicted rating: 4.319131423502716
movid id: Usual Suspects, The (1995), movie genre: Crime|Mystery|Thriller,predicted rating: 4.297686128819139
movid id: Spirited Away (Sen to Chihiro no kamikakushi) (2001), movie genre: Adventure|Animation|Fantasy,predicted rating: 4.297640790120129
movid id: Streetcar Named Desire, A (1951), movie genre: Drama,predicted rating: 4.291907457874597
movid id: Monty Python and the Holy Grail (1975), movie genre: Adventure|Comedy|Fantasy,predicted rating: 4.288184773024098
movid id: Fight Club (1999), movie genre: Action|Crime|Drama|Thriller,predicted rating: 4.285619921616426

+ Recent posts