본문 바로가기

Data Science/추천시스템과 NLP

[NLP] Collaborative Filtering(Recommendation)

반응형

 

 

이번 포스팅에서는 저번 시간의 컨텐츠 기반 필터링(추천) 방법과는 다른 추천 알고리즘인 협업 필터링(Collaborative Filtering)에 대해 알아보고 코드로 구현하는 방법에 대해 알아본다. 협업 필터링사용자 또는 아이템(컨텐츠) 기반인 최근접 이웃 필터링잠재(Latent)요인 기반 필터링으로 나뉘어 진다. 그러므로 각 개념을 설명하면서 코드로 구현하는 방법 순으로 전개된다. 목차는 다음과 같다.

 

1. 협업 필터링의 종류

2. 최근접 이웃(Nearest-Neighbor) 기반 필터링

3 최근접 이웃 기반 필터링 구현하기

4. 잠재(Latent)요인 기반 필터링

5. 잠재요인 기반 필터링 구현하기

1. 협업 필터링의 종류

먼저 협업 필터링(Collaborative Filtering)은 다음과 같이 구분된다.

 

  • 최근접 이웃 기반 필터링 
    • 사용자 - 사용자 기반 추천 방식 : 특정 사용자와 비슷한 고객들을 기반으로 이 비슷한 고객들이 선호하는 다른 상품을 특정 사용자에게 추천한다. 현실세계에서 "당신과 비슷한 고객들이 다음 상품도 구매했습니다." 라는 문구가 대표적인 예시가 되겠다.
    • 아이템 - 아이템 기반 추천 방식 : 특정 상품과 유사하며 좋은 평가를 받은 다른 비슷한 상품을 추천한다. 즉, 사용자들로부터 특정 상품과 비슷한 평가를 받은 상품을을 비슷한 상품으로 간주한다. 현실세계에서 "이 상품을 선택한 다른 고객들은 다음 상품도 구매했습니다" 라는 문구가 대표적인 예시이다.
  • 잠재요인 기반 필터링 : 사용자-아이템 평점 행렬 속에 숨어 있는 잠재(latent)요인을 추출해 추천 예측을 하는 기법이다. 잠재 요인을 추출하기 때문에 SVD와 같은 행렬분해 기법을 사용한다.

https://realpython.com/build-recommendation-engine-collaborative-filtering/

 

2. 최근접 이웃 기반 필터링

최근접 이웃 기반 필터링은 위에서 설명했다시피 사용자, 아이템 기반 추천 방식 2가지로 나뉜다. 보통은 아이템 기반의 추천방식을 주로 사용한다. 왜냐하면 사용자 기반의 추천 방식은 특정 사용자를 비슷한 다른 사용자들과 특성이 거의 비슷함을 가정하는데, 이는 개개인마다 기호, 성격이 각기 너무 다른 현실세계의 변수를 제대로 반영하지 못하기 때문이다.

 

따라서 비슷함의 대상을 아이템(상품)으로 설정하는 아이템 기반 추천방식이 주로 사용된다.

 

(최근접 이웃 기반 필터링의 종류)https://medium.com/@cfpinela/recommender-systems-user-based-and-item-based-collaborative-filtering-5d5f375a127f

 

사용자 기반 필터링과 아이템 기반 필터링은 각각 다음과 같은 데이터셋으로 구성된다. 우선 사용자 기반 필터링의 데이터셋 형태를 살펴보자.

 

사용자 기반 필터링을 위한 데이터셋 형태 - https://takuti.github.io/Recommendation.jl/latest/collaborative_filtering/

 

위 데이터셋에서 행은 사용자, 열은 아이템을 나타낸다. 즉, a라는 사람에 대해서 ?칸으로 되어 있는 곳에 +/- 둘 중 어느것이 들어갈지 다른 사람 즉 b,c,d,e 사람들의 각각 행 데이터(행 벡터)들에 기반해 ?칸을 채워주는 것이다. 그렇다면 아이템 기반 필터링의 데이터셋은 어떻게 될까?

 

바로 위 데이터셋에서 Transpose(전치행렬)를 취해주는 것이다. Transpose란, 해당 행렬에서 행과 열의 위치를 서로 바꾸어주는 것이다. Transpose를 취하고 나면 행에 아이템, 열에 사용자가 들어가게 되며, 결국 하나의 아이템에 대한 모든 사용자들의 +/- 평가가 하나의 행(row)로 들어가게 된다. 

 

3. 최근접 이웃 기반 필터링 구현하기

이제 최근접 이웃 기반 필터링, 그 중에서도 아이템에 기반한 필터링 추천 방식을 직접 구현해보자. 아이템 기반 필터링 추천 방식 프로세스는 다음과 같다.

 

  1. 사용자-아이템 행렬 데이터(즉, 사용자 기반 필터링 데이터셋 형태)에서 Transpose를 취해 아이템-사용자 행렬 데이터로 변환해준다.
  2. Cosine Similarity를 이용해서 아이템들간의 코사인 유사도로 아이템들 간 유사도를 산출한다.
  3. 1단계에서 변환한 아이템-사용자 행렬 데이터에서 각 아이템에 대한 사용자들의 평점과 2단계에서 구한 아이템들 간의 유사도 값을 이용해서 개인화된 평점을 예측해 계산한다.
  4. 위에서 예측 계산된 개인화된 평점을 기준으로 가장 높은 순으로 정렬을 한 후 상위의 상품들을 추천한다.

위 과정에서 알아두어야 할 점은 3번에서 '개인화된 평점'을 계산하는 단계이다. 개인화된 평점을 계산하는 이유는 제품에 대한 평점과 제품들간의 유사도값 이 두개를 잘 활용하여 사용자에게 적당한 다른 상품을 추천하기 위함 때문이다. 물론 계산공식은 따로 존재한다. 다음 그림을 보자.

 

개인화된 평점 계산 공식

 

위 그림에서 R은 특정 사용자 u가 구입한 아이템 i와 유사도가 높은 상위의 다른 아이템 j ~ n까지에 대한 평점이다. S사용자 u가 구입한 아이템 i와 다른 아이템 j~n과의 유사도 값들이다. 그런 다음 아이템-사용자 행렬 데이터에서 각 아이템에 대한 사용자들의 평점과 해당 아이템에 대한 다른 아이템들간의 유사도값들을 내적하여 개인화된 평점 예측값을 산출한다.

 

이렇게 찾아낸 하나의 행벡터와 열벡터를 서로 내적하여 개인화된 평점을 계산하게 된다. 이제 저번 포스팅에서도 사용했던 Kaggle의 TMDB Movies 데이터를 활용해 구현해보자.

 

우선 데이터를 로드하고 사용자-아이템 행렬 데이터(사용자 기반 필터링 데이터 형태)로 만들자.

 

import pandas as pd
import numpy as np
movies = pd.read_csv('movies.csv', encoding='utf-8')
ratings = pd.read_csv('ratings.csv', encoding='utf-8')
# moviesId를 key로 하여 두개의 데이터프레임 merge하기
# 필요없는 칼럼인 timestamp 삭제
ratings = ratings.drop('timestamp', axis=1)
# ratings_matrix의 칼럼인 movieId를 텍스트인 영화이름으로 가져오기 위해서
# movies 데이터의 title칼럼 사용하자!
# 그러기 위해서 우선 두개의 데이터프레임을 merge하자
rating_movies = pd.merge(ratings, movies, on='movieId')

# 다시 pivot_table이용해서 user-item 데이터셋으로 만들기
rating_movies_matrix = rating_movies.pivot_table(values='rating',
                                                index='userId',
                                                columns='title')
# 결측치 값을 모두 0으로 채우기(평점이 매겨지지 않은 것임!)
rating_movies_matrix = rating_movies_matrix.fillna(0)
print(rating_movies_matrix.head(3))

 

현재 데이터셋 구성은 다음과 같다. 행은 사용자, 열은 영화(아이템)으로 이루어진 형태이다.

 

사용자-아이템 데이터셋

 

이제 위 데이터셋을 Transpose 하여 아이템 기반 필터링 데이터셋 형태로 만들어주고 각 영화들(아이템들)간의 코사인 유사도를 구해보자.

 

# 이제 user-item기반 데이터셋을 만들었으니까 item(영화들)간의 유사도 산출
# 코사인 유사도 이용
# 우선 user-item 에서 item-user 데이터셋으로 변환하기 위해 Transpose()
rating_movies_matrix_T = rating_movies_matrix.transpose()

# sklearn을 이용해서 코사인 유사도 도출
from sklearn.metrics.pairwise import cosine_similarity

# 코사인 유사도 측정할 때, 매트릭스의 행 vector끼리 유사도 비교!
item_sim = cosine_similarity(rating_movies_matrix_T,
                            rating_movies_matrix_T)

# 코사인 유사도 행렬의 칼럼, 인덱스값을 item(영화)이름을 넣어서 데이터프레임 생성
item_sim_df = pd.DataFrame(item_sim, index=rating_movies_matrix.columns,
                          columns=rating_movies_matrix.columns)
print(item_sim_df.shape)
print(item_sim_df.head())

 

행, 열 모두 영화(아이템)들로 이루어져있고 마치 상관계수 행렬처럼 영화들 간 서로의 유사도 값들이 출력되어 있는 것을 볼 수 있다.

 

영화들간의 유사도 행렬

 

이제 아이템-사용자 데이터셋과 아이템(영화)들간의 유사도 행렬을 내적해줌으로써 개인화된 평점 예측값을 산출해보자.

 

def predict_rating(ratings_arr, item_sim_arr):
    # 2차원 array로 만들어야 하므로 분모에 np.abs를 하나의 array안에 담아주자!
    ratings_pred = ratings_arr.dot(item_sim_arr) / np.array([np.abs(item_sim_arr).sum(axis=1)])
    return ratings_pred
    
 # 개인화 평점 계산해서 user-item 데이터셋으로 만들기
ratings_pred = predict_rating(rating_movies_matrix.values,
                             item_sim_df.values)
rating_pred_df = pd.DataFrame(ratings_pred,
                                 index=rating_movies_matrix.index,
                                 columns=rating_movies_matrix.columns)
print(rating_pred_df.shape)
print(rating_pred_df.head())                                 

 

결과값은 다음과 같으며 이제 각 아이템에 대해서 각 사용자들마다 개인화된 평점예측값이 도출되었다. 각 행(사용자)의 값들 중에 값이 높을수록 해당 사용자에게 추천할만한 영화들임을 추측해볼 수 있다.

 

각 사용자마다 각 아이템에 대해 개인화된 평점 예측 데이터프레임

 

4. 잠재요인 기반 필터링

잠재요인 기반은 SVD라는 행렬 분해기법을 사용하여 잠재된 요인을 추출하는 방식이다. SVD에 대한 이론은 여기, SVD를 Python으로 구현하는 코드는 여기를 참고하자. 

 

여기서 사용하는 SVD 행렬 분해의 목표는 희소(Sparse) 행렬 형태의 사용자-아이템 평점 행렬 밀집(Dense) 행렬의 형태인 사용자-잠재요인 행렬과 잠재요인-아이템 행렬로 분해한 뒤 다시 결합(내적)하여 사용자-아이템 평점 행렬로 재 생성하기 위함이다. 이렇게 사용자-아이템 평점 행렬을 재 생성하게 되어 희소한 행렬의 값에 예측값을 부여하여 새로운 아이템을 추천할 수 있는 것이다.

 

(SVD를 활용한 사용자-아이템 행렬 분해) - https://velog.io/@vvakki_/Matrix-Factorization-2

 

하지만 SVD는 결측치가 없는 행렬에만 적용이 가능한데, 우리가 예측하려는 원본 사용자-아이템 평점 행렬은 희소한 행렬이기 때문에 문제가 발생한다. 이를 해결하기 위해서 경사 하강법(Gradient Descent)를 이용해야 한다.

 

그런데 경사 하강법이더라도 애초에 최초의 값이 존재해야 Cost function을 미분함으로써 error을 줄여갈 수 있지 않은가? 이에 대해서는 위의 그림을 보고 예시를 들어 설명하겠다. 우선 두 개의 행렬을 내적해서 원본행렬(사용자-아이템 행렬)의 U by I 크기가 되도록  U by F, F by I 크기의 두 개의 행렬크기로 정규분포를 가정한 임의의값이 들어있는 두 개의 행렬을 생성한다.(이 때 F는 사용자가 정의해준다.) 

 

그리고 이 생성한 두 개의 행렬 즉, U by F 행렬, F by I 행렬을 내적하면 원본행렬(R행렬이라고 하자.) U by I 크기만큼의 행렬이 만들어진다. 이 행렬을 R'행렬이라고 하자. 그럼 R행렬과 R'행렬 두 개를 나란히 놓고 요소값들을 비교해보자. R행렬은 희소행렬 상태이기 때문에 결측치 값들이 존재한다. 하지만 R'행렬은 임의의 값으로 주어진 행렬들의 내적한 결과이기 때문에 결측치 값이 존재하지 않는다.

 

이 상태에서 R행렬에서 결측치가 아닌 행렬 값들의 인덱스를 추출하여 R'행렬에 똑같은 인덱스 위치의 행렬 값들을 찾아본다. 그리고 R행렬과 R'행렬 간의 같은 인덱스 위치에 존재하는 값들 끼리의 차이를 계산함으로써 일종의 error, 즉 , Cost function을 계산해낼 수 있다.

 

이제 Cost function을 찾았으니 이 값을 최소화시키도록 행렬의 값들을 업데이트 해주면 된다. 업데이트 수식은 다음과 같다.

 

행렬 값(파라미터) 업데이트 수식

 

위 공식은 거대한 것이 아닌 우리가 평소에 배워왔던 Squared error의 합에 Regularization 방법 중 하나인 L2 norm을 추가하는 수식이다.

이 수식을 활용해 Cost 값이 최소가 되도록 행렬의 값을 업데이트하고(이제 이 과정에서 Gradient Descent가 수행되는 것이다.) 모든 업데이트가 종료된 후의 행렬값들로 이루어진 U by F, F by I 두 행렬이 반환될 것이다. 이 두개를 내적하여 최종적인 R'행렬을 산출한다. 그리고 이 R'행렬을 기반으로 해서 원본 R행렬의 결측치 값들이 들어있는 인덱스의 위치와 동일하게 R'행렬의 똑같은 위치 인덱스로 가서 그 값을 예측값으로 간주하는 것이다.

 

5. 잠재요인 기반 필터링 구현하기

이제 위와 같은 긴 과정들을 코드로 직접 구현해보자. 쉬운 예시로 임의로 원본행렬 R을 희소행렬로 만들고 이를 SVD와 Gradient Descent를 이용해서 어떻게 예측값을 산출하는지 살펴보자. 먼저 원본행렬 R을 SVD로 분해한 크기만큼 임의의 행렬 P, Q를 생성하자.

 

import numpy as np

# 원본 행렬 R 생성, 분해 행렬 P와 Q 초기화, 잠재요인 차원 K는 3 설정. 
R = np.array([[4, np.NaN, np.NaN, 2, np.NaN ],
              [np.NaN, 5, np.NaN, 3, 1 ],
              [np.NaN, np.NaN, 3, 4, 4 ],
              [5, 2, 1, 2, np.NaN ]])

# shape 행,열 두 개 변수에 한 줄로 할당하기
num_users, num_items = R.shape
print(num_users, num_items)
# 잠재요인 factor 개수
K=3

# P와Q 매트릭스의 크기를 지정하고 정규분포를 가진 random한 값으로 P,Q행렬 생성
# 난수 시드 생성
np.random.seed(1)
# P행렬 : 사용자 - 잠재요인 행렬
P = np.random.normal(scale=1./K, size=(num_users, K))
# Q행렬 : 아이템 - 잠재요인 행렬(실제 분해하게되면 Q의 Transpose행렬로 됨!)
Q = np.random.normal(scale=1./K, size=(num_items, K))
print('P:', P)
print('Q:', Q)

 

임의로 부여한 P, Q행렬은 다음과 같다. 이 두개의 행렬을 이용해 경사하강법을 적용할 것이다.

 

임의의 행렬 P,Q

 

다음은 원본 행렬 R에서 결측치가 아닌 값들의 인덱스를 갖고와서  P,Q행렬을 내적하여 만든 R_행렬의 동일한 인덱스에 있는 값들 끼리 비교를 하여 Error(Cost)값을 출력해보자.

 

from sklearn.metrics import mean_squared_error

def get_rmse(R, P, Q, non_zeros):
    # 두개의 분해된 행렬 P와 Q의 전치행렬 냐적으로 예측행렬 R' 생성
    R_ = np.dot(P, Q.T)
    
    # 실제 R행렬에서 NaN값이 아닌값들의 인덱스 위치와 값을 추출해서
    # 실제 R행렬과 예측 R'행렬 간의 RMSE비교
    x_non_zero_idx = [non_zero[0] for non_zero in non_zeros] #행
    y_non_zero_idx = [non_zero[1] for non_zero in non_zeros] #열
    Rnon_zeros = R[x_non_zero_idx, y_non_zero_idx] #실제 R행렬의 NaN아닌 값들
    R_non_zeros = R_[x_non_zero_idx, y_non_zero_idx] #예측 R'행렬에서 똑같은 위치의 값들을 뽑아내기
    # 1차원의 array 2개 값들을 각각 비교
    mse = mean_squared_error(Rnon_zeros, R_non_zeros)
    rmse = np.sqrt(mse)
    
    return rmse

 

Error을 구하는 함수를 정의했으니 이제 행렬값(파라미터) 업데이트 수식을 활용해 경사 하강법을 사용해보자.

 

# 위 함수에서 non_zeros에 해당하는 값들 리스트에 저장
non_zeros = [(i, j, R[i, j]) for i in range(num_users) for j in range(num_items) if R[i, j] > 0]

steps = 1000
learning_rate = 0.01
r_lambda = 0.01

# Stochastic Gradient Descent 방법으로 P와 Q 매트릭스를 계속 업데이트
for step in range(steps):
    for i, j, v in non_zeros:
        #실제 R행렬의 특정값과 예측행렬 R'의 똑같은 위치의 특정값의 차이(오류)를 구하기
        eij = v - np.dot(P[i, :], Q[j, :].T)
        # 정규화를 반영한 SGD 업데이트 공식 적용
        P[i, :] = P[i, :] + learning_rate*(eij*Q[j, :] - r_lambda*P[i,:])
        Q[j, :] = Q[j, :] + learning_rate*(eij*P[i, :] - r_lambda*Q[j, :])
    # 1번 step돌때마다 예측행렬 R'의 특정 인덱스(실제행렬 R에서 NaN값이 아닌 위치인덱스들)의 값들 업데이트
    # get_rmse함수는 실제행렬 R에서 NaN값이 아닌 값들과 예측행렬 R'의 특정 인덱스 값들만을 비교
    # 단, 예측행렬 R'의 모든 요소값들은 업데이트 되었음. RMSE값을 도출하기 위해서 특정 위치의 값들만 비교를 한 것임!
    rmse = get_rmse(R, P, Q, non_zeros)
    # 50번 step수행할 때마다 출력하기, %는 나머지 값
    if (step % 50) == 0:
        print('### iteration step: ', step, "RMSE: ", rmse)

 

다음과 같이 반복횟수가 증가함에 따라  Error인 RMSE 값이 줄어드는 것을 볼 수 있다.

 

경사하강법을 통해 Cost를 최소화시키자.

 

이제 임의로 부여했던 P, Q행렬의 값들이 경사하강법을 이용해 업데이트 됬을 것이다. 이 두개를 내적하여 최종 예측 행렬을 살펴보고 원본 행렬과 비교해보자.

 

# 업데이트된 P,Q행렬 값들로 내적해서 예측행렬 R' 만들기
final_R_ = np.dot(P, Q.T)
print('예측 행렬 :\n', np.round(final_R_, 3))

최종 예측 행렬
원본 행렬

 

위 두 개의 행렬 값들을 비교해보면서 원본 행렬의 결측치가 어떠한 값으로 예측됬는지 살펴보자. 원본 행렬에서 결측치가 아니었던 값들이 예측 행렬에서 값이 크게 변하지 않았음을 볼 수 있다. 이는 결국 Error값이 최소화 되었음을 의미하기도 한다.

 

참고자료 : 파이썬 머신러닝 완벽 가이드

 

반응형