본문 바로가기

Euron/정리

[Week17] 09. 추천 시스템

01. 추천 시스템의 개요와 배경

추천 시스템의 개요

 

적절한 추천 시스템은 사이트의 매출 증가에 도움이 된다.

추천 시스템을 접한 사용자는 더 많은 추천 콘텐츠를 선택하게 되고, 결국 더 많은 데이터가 축적되며 더욱 정확하고 다양한 결과를 얻을 수 있게 된다.

 

온라인 스토어의 필수 요소, 추천 시스템

 

추천 시스템은 특히 온라인 스토어의 필수 요소이다.

많은 양의 고객과 상품 관련 데이터를 가지고 있으며, 사용자가 흥미를 가질 만한 상품을 즉각적으로 추천하는데 사용된다.

 

추천 시스템의 유형

 

추천 시스템은 크게 콘텐츠 기반 필터링(Content based filtering) 방식과 협업 필터링(Collaborative Filtering) 방식으로 나뉘며, 협업 필터링 방식은 다시 최근접 이웃(Nearest Neighbor) 협업 필터링과 잠재 요인(Latent Factor) 협업 필터링으로 나뉜다.

 

초창기는 콘텐츠 기반 필터링이나 최근접 이웃 기반 협업 필터링이 주로 사용됐으나, 최근 넷플릭스 추천 시스템 경연 대회 우승으로 잠재 요인 협업 필터링 기반의 추천 시스템을 대부분 사용한다.

그러나 서비스 특성에 따라 아마존과 같은 경우는 아이템 기반의 최근접 이웃 협업 필터링 방식을 추천 엔진으로 사용한다.

요즘에는 개인화 특성을 강화하기 위해 하이프리드 형식으로 콘텐츠 기반과 협업 기반을 적절히 결합해 사용하고 있다.

 


02. 콘텐츠 기반 필터링 추천 시스템

콘텐츠 기반 필터링 방식은 사용자가 특정한 아이템을 매우 선호하는 경우, 그 아이템과 비슷한 콘텐츠를 가진 다른 아이템을 추천하는 방식이다.

예시


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

최근접 이웃 협업 필터링이란, 사용자가 아이템에 매긴 평점 정보나 상품 구매 이력과 같은 사용자 행동 양식(User Behavior)만을 기반으로 추천을 수행하는 것이다.

 

협업 필터링의 주요 목표는 사용자-아이템 평점 매트릭스와 같은 축적된 사용자 행동 데이터를 기반으로 사용자가 아직 평가하지 않은 아이템을 예측 평가(Predicted Rating)하는 것이다.

 

협업 필터링 기반의 추천 시스템은 최근접 이웃 방식과 잠재 요인 방식으로 나뉘며, 두 방식 모두 사용자-아이템 평점 행렬 데이터만 의지해 추천을 수행한다.

협업 필터링 알고리즘에 사용되는 사용자-아이템 평점 행렬에서 행(Row)은 개별 사용자, 열(Column)은 개별 아이템으로 구성되며, 사용자 아이디 행, 아이템 아이디 열에 위치에 해당하는 값이 평점을 나타내는 형태가 돼야 한다.

판다스의 pivot_table()과 같은 함수를 이용해 사용자-아이템 평점 행렬 형태로 변경한다.

레코드 레밸 형태 데이터를 평점 행렬 형태로 변환

이러한 사용자-아이템 행렬은 많은 아이템을 열로 가지며, 희소 행렬 특성을 가진다.

 

최근접 이웃 협업 필터링은 메모리 협업 필터링이라고도 하며, 두가지로 다시 나눌 수 있다.

  • 사용자 기반(User-User) : 당신과 비슷한 고객들이 다음 상품도 구매했습니다. 특정 사용자와 타 사용자 간의 유사도(Similarity)를 측정한 뒤 가장 유사도가 높은 Top-N 사용자를 추출해 그들이 선호하는 아이템을 추천한다.
  • 아이템 기반(Item-Item) : 이 상품을 선택한 다른 고객들은 다음 상품도 구매했습니다. 아이템이 가지는 속성과는 상관없이 사용자들이 그 아이템을 좋아하는지/싫어하는지의 평가 척도가 유사한 아이템을 추천하는 기준이 된다.

일반적으로 아이템 기반 협업 필터링의 정확도가 더 높다.

또한 유사도 측정 방법은 텍스트 분석에서도 사용된 코사인 유사도 측정 방식이 많이 적용된다.


04. 잠재 요인 협업 필터링

잠재 요인 협업 필터링의 이해

 

잠재 요인 협업 필터링은 사용자-아이템 평점 매트릭스 속에 숨어 있는 잠재 요인을 추출해 추천 예측을 할 수 있게 하는 기법이다.

대규모 다차원 행렬을 SVD와 같은 차원 축소 기법으로 분해하는 과정에서 잠재 요인을 추출하는데, 이러한 기법을 행렬 분해(Matrix Factorization)라고 한다.

 

'잠재 요인'을 명확히 정의할 수는 없지만, 잠재 요인을 기반으로 다차원 희소 행렬인 사용자-아이템 행렬 데이터를 저차원 밀집 행렬의 사용자-잠재 요인 행렬과 아이템-잠재 요인 행렬의 전치 행렬로 분해할 수 있다.

이렇게 분해된 두 행렬의 내적을 통해 새로운 예측 사용자-아이템 평점 행렬 데이터를 만들어서 사용자가 아직 평점을 부여하지 않은 아이템에 대한 예측 평점을 생성한다.

예측 평점 생성 과정

 

사용자 아이템 평점 행렬의 각 평점은 다음과 같이 구할 수 있다.

평점 구하기

R(u,i)에서 u는 사용자 아이디, i는 아이템 아이디이다.

P(u,k)에서 u는 사용자 아이디, k는 잠재 요인 칼럼(장르별 선호도)이다.

Q(i,k)에서 i는 아이템 아이디, k는 잠재 요인 칼럼(장르별 요소)이다. 내적 계산을 위해 Q의 행과 열을 교환한 Q.T로 변환한다.

 

즉, 평점은 사용자의 장르별 선호도 벡터와 장르별 특성 벡터를 서로 곱해서 만들 수 있다.

이제 user1이 평점을 매기지 못한 Item2에 대한 예측 평점 또한 구할 수 있다.

예측 평점 구하기

 

행렬 분해

 

행렬 분해는 다차원의 매트릭스를 저차원의 매트릭스로 분해하는 기법으로써 대표적으로 SVD, NMF 등이 있다.

M개의 사용자 행과 N개의 아이템 열을 가진 평점 행렬 R은 MxN 차원으로 구성되며, 행렬 분해를 통해 사용자-K 차원 잠재 요인 행렬 P(MxK)와 K차원 잠재 요인-아이템 행렬 Q.T(KxN)로 분해될 수 있다.

 

즉, R=P*Q.T이다.

R=P*Q.T

 

사용자가 평가하지 않은 아이템에 대한 평가도 잠재 요인으로 분해된 P행렬과 Q행렬을 이용해 예측할 수 있다.

 

행렬 분해는 주로 SVD 방식을 이용하지만, SVD는 널값이 없는 행렬에만 적용할 수 있다.

이러한 경우 확률적 경사 하강법이나 ALS 방식을 이용해 SVD를 수행한다.

 

확률적 경사 하강법을 이용한 행렬 분해

 

P와 Q행렬로 계산된 예측 R행렬 값이 실제 R행렬 값과 가장 최소의 오류를 가질 수 있도록 반복적인 비용 함수 최적화를 통해 P와 Q를 유추해내는 것이다.

 

절차

  1. P와 Q를 임의의 값을 가진 행렬로 설정한다.
  2. P와 Q.T의 값을 곱해 예측 R행렬을 계산하고 예측 R행렬과 실제 R행렬에 해당하는 오류 값을 계산한다.
  3. 이 오류 값을 최소화할 수 있도록 P와 Q행렬을 적절한 값으로 각각 업데이트한다.
  4. 만족할만한 오류 값을 가질 때까지 2,3번 작업을 반복하면서 P와 Q값을 업데이트해 근사화한다.

실제 값과 예측값의 오류 최소화와 L2 규제를 고려한 비용 함수식은 다음과 같다.

 

이제 비용 함수식을 최소화 하기 위해 새롭게 업데이트 되는 벡터는 다음과 같이 계산한다.

L2 규제를 반영해 실제 R행렬 값과 예측 R행렬 값의 차이를 최소화 하는 방향성을 가지고 P행렬과 Q행렬에 업데이트 값을 반복적으로 수행하면서 최적화된 예측 R행렬을 구하는 방식이 SGD 방식의 행렬 분해이다.

 

위 행렬 분해를 파이썬으로 구현해본다.

먼저 원본 행렬 R을 NaN 값을 포함해 생성하고, 분해 행렬 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 ]])
num_users, num_items = R.shape
K=3

# P와 Q 매트릭스의 크기를 지정하고 정규분포를 가진 random한 값으로 입력합니다. 
np.random.seed(1)
P = np.random.normal(scale=1./K, size=(num_users, K))
Q = np.random.normal(scale=1./K, size=(num_items, K))

 

다음으로 실제 R행렬과 예측 행렬의 오차를 구하는 get_rmse() 함수를 만든다.

from sklearn.metrics import mean_squared_error

def get_rmse(R, P, Q, non_zeros):
    error = 0
    # 두개의 분해된 행렬 P와 Q.T의 내적으로 예측 R 행렬 생성
    full_pred_matrix = np.dot(P, Q.T)
    
    # 실제 R 행렬에서 널이 아닌 값의 위치 인덱스 추출하여 실제 R 행렬과 예측 행렬의 RMSE 추출
    x_non_zero_ind = [non_zero[0] for non_zero in non_zeros]
    y_non_zero_ind = [non_zero[1] for non_zero in non_zeros]
    R_non_zeros = R[x_non_zero_ind, y_non_zero_ind]
    full_pred_matrix_non_zeros = full_pred_matrix[x_non_zero_ind, y_non_zero_ind]
      
    mse = mean_squared_error(R_non_zeros, full_pred_matrix_non_zeros)
    rmse = np.sqrt(mse)
    
    return rmse

 

이제 SGD 기반으로 행렬 분해를 수행한다.

먼저 R에서 널 값을 제외한 데이터의 행렬 인덱스를 추출한다.

steps는 SGD를 반복해서 업데이트할 횟수, learning_rate는 SGD의 학습률, r_lambda는 L2 규제 계수이다.

# R > 0 인 행 위치, 열 위치, 값을 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

# SGD 기법으로 P와 Q 매트릭스를 계속 업데이트. 
for step in range(steps):
    for i, j, r in non_zeros:
        # 실제 값과 예측 값의 차이인 오류 값 구함
        eij = r - np.dot(P[i, :], Q[j, :].T)
        # Regularization을 반영한 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,:])

    rmse = get_rmse(R, P, Q, non_zeros)
    if (step % 50) == 0 :
        print("### iteration step : ", step," rmse : ", rmse)
### iteration step :  0  rmse :  3.2388050277987723
### iteration step :  50  rmse :  0.4876723101369648
### iteration step :  100  rmse :  0.1564340384819247
### iteration step :  150  rmse :  0.07455141311978046
### iteration step :  200  rmse :  0.04325226798579314
### iteration step :  250  rmse :  0.029248328780878973
### iteration step :  300  rmse :  0.022621116143829466
### iteration step :  350  rmse :  0.019493636196525135
### iteration step :  400  rmse :  0.018022719092132704
### iteration step :  450  rmse :  0.01731968595344266
### iteration step :  500  rmse :  0.016973657887570753
### iteration step :  550  rmse :  0.016796804595895633
### iteration step :  600  rmse :  0.01670132290188466
### iteration step :  650  rmse :  0.01664473691247669
### iteration step :  700  rmse :  0.016605910068210026
### iteration step :  750  rmse :  0.016574200475705
### iteration step :  800  rmse :  0.01654431582921597
### iteration step :  850  rmse :  0.01651375177473524
### iteration step :  900  rmse :  0.01648146573819501
### iteration step :  950  rmse :  0.016447171683479155

 

이제 분해된 P와 Q함수를 P*Q.T로 예측 행렬을 만들어서 출력한다.

pred_matrix = np.dot(P, Q.T)
print('예측 행렬:\n', np.round(pred_matrix, 3))
예측 행렬:
 [[3.991 0.897 1.306 2.002 1.663]
 [6.696 4.978 0.979 2.981 1.003]
 [6.677 0.391 2.987 3.977 3.986]
 [4.968 2.005 1.006 2.017 1.14 ]]

원본 행렬과 비교해 널이 아닌 값은 큰 차이가 나지 않고, 널인 값은 새로운 예측값으로 채워진 것을 확인할 수 있다.

 


# 회고

 

기대하던 추천 시스템!

내 생각보다 훨씬 종류도 많고 복잡하다.

행렬 분해까지 해가면서 예측값을 구할 줄은 몰랐다...이래서 넷플릭스가 흥행한건가...