Go together

If you want to go fast, go alone. If you want to go far, go together.

파이썬 머신 러닝 완벽 가이드

추천 시스템

NowChan 2021. 12. 11. 14:31
recommend

추천 시스템의 개요와 배경

추천 시스템의 개요

추천 엔진에 사용될 수 있는 데이터는 아래와 같습니다.

  • 사용자가 어떤 상품을 구매했는가?
  • 사용자가 평가한 영화 평점은? 제품 평가는?
  • 사용자가 스스로 작성한 자신의 취향은?
  • 사용자가 무엇을 클릭했는가?

추천 시스템의 유형

추천 시스템은 크게 아래와 같이 나뉩니다.

  • 콘텐츠 기반 필터링(Content based filtering) 방식
  • 협업 필터링(Collaborative filtering) 방식
    • 최근접 이웃(Nearest Neighbor)
    • 잠재 요인(Latent Factor)

추천시스템의 초창기에는 콘텐츠 기반 필터링, 최근접 이웃 협업 필터링 방식이 주로 사용됐지만, 넷플릭스 추천 시스템 경연 대회에서 행렬 분해(Matrix Factorization) 기법을 이용한 잠재 요인 협업 필터링 기법이 우승한 이후로 잠재 요인 방식이 주를 이룹니다. 아마존의 경우 아이템 기반 최근접 이웃 방식을 추천 엔진으로 사용하고, 하이브리드 형식으로 콘텐츠 기반, 협업 기반을 섞는 곳도 증가하고 있습니다.

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

콘텐츠 기반 필터링은 사용자가 특정한 아이템을 선호하는 경우, 그 아이템과 비슷한 다른 아이템을 추천하는 방식입니다. 예를 들어, 사용자가 '컨텍트'라는 영화와 '프로메테우스'라는 영화에 좋은 평점(9/10점)을 줬다고 합시다. '컨텍트'는 SF, 미스터리 장르이고, 프로메테우스는 SF, 스릴러, 액션 장르이고, 영화의 감독은 각각 드니 빌뇌브, 리들리 스콧입니다. '블레이드 러너 2049'는 리들리 스콧이 만든 원작 '블레이드 러너를' 드니 빌뇌브 감독이 리메이크한 작품인데, 콘텐츠 기반 필터링 추천 시스템에 의해서는 영화의 장르, 감독, 키워드 등을 이용해 이 작품을 추천해 줄 수 있습니다.

협업 방식은 사용자가 아이템에 매긴 평점 정보나 상품 구매 이력과 같은 사용자 행동 양식(User Behavior)만을 기반으로 추천을 수행하는 방식입니다.
RubberDuck

왼쪽 그림에서 User1은 Item4에 대한 평점이 없습니다.
협업 필터링은 사용자가 평가한 다른 아이템을 기반으로 사용자가 평가하지 않은 아이템의 평가를 예측하는 방식입니다.

협업 필터링 기반의 추천 시스템은 최근접 이웃 방식잠재 요인 방식으로 나뉘며, 두 방식 모두 사용자-아이템 평점 행렬 데이터에만 의지해 추천을 수행합니다. Row(행)은 개별 사용자, Column(열)은 개별 아이템으로 구성되며, 각 행, 열에는 그 위치에 해당하는 평점이 있어야 합니다.

RubberDuck
이렇게 로우 레벨 형태의 데이터는 사용자 로우, pd.pivot_table()을 이용해 아이템 칼럼 평점 데이터로 바꾸어 줘야 합니다.

일반적으로 이러한 사용자-아이템 평점 행렬은 많은 아이템을 가지는 다차원 행렬이고 사용자가 아이템에 대한 평점을 매기는 경우는 많지 않기 때문에 희소 행렬(Sparse Matrix) 특성을 가지고 있습니다. 최근접 이웃 협업 필터링은 메모리(Memory) 협업 필터링이라고도 하며, 일반적으로 사용자 기반과 아이템 기반으로 다시 나눌 수 있습니다.

  • 사용자 기반(User-User): 당신과 비슷한 고객들이 다음 상품도 구매했습니다.
  • 아이템 기반(Item-Item): 이 상품을 선택한 다른 고객들은 다음 상품도 구매했습니다.

사용자 기반 최근접 이웃 방식은 특정 사용자와 타 사용자 간에 유사도(Similrarity)를 측정한 뒤, 유사한 사용자를 Top-N명 선정해 Top-N 사용자가 좋아하는 아이템을 추천하는 방식입니다. 다음 그림을 통해 설명해보겠습니다.

RubberDuck

사용자 A는 사용자 C보다 평점 정보가 유사하므로 즉, 사용자 B와 영화 평점의 유사도가 높으므로 스타워즈가 아닌 프로메테우스를 추천합니다.

아이템 기반 최근접 이웃 방식은 '아이템 간 의 속성'과는 전혀 무관합니다. 아이템 속성과는 상관없이 특정 사용자에게 평점이 유사한 사용자들이 선호하는(평점이 높은) 아이템을 추천하는 기준이 되는 알고리즘입니다. 아이템 기반 최근접 이웃 방식의 데이터 세트사용자 기반 최근접 이웃 방식과 행과 열이 서로 반대입니다.

RubberDuck
'다크 나이트'는 '스타워즈'보다 '프로메테우스'와 평점 분포가 비슷하므로 '다크나이트'와 '프로메테우스'는 상호 간 유사도가 높은 아이템입니다. 따라서 사용자D에게는 '프로메테우스'를 추천합니다.

일반적으로 아이템 기반 협업 필터링이 사용자 기반보다 정확도가 높습니다. 사용자 기반의 경우 비슷한 영화를 좋아한다고해서 그 사람들의 취향이 비슷한 경우는 많이 없기 때문입니다. 매우 유명한 영화는 취향과 관계없이 대부분 관람하는 경우가 많고, 또한 사용자들이 평점을 매긴 영화의 개수가 많지 않기 때문입니다. 따라서 최근접 이웃 협업 필터링은 대부분 아이템 기반의 알고리즘을 적용합니다.

앞장의 텍스트 분석에서 소개된 유사도 측정 방법인 코사인 유사도는 추천 시스템의 유사도 측정에 가장 많이 적용됩니다. 추천 시스템에 사용되는 데이터 또한 피처 벡터화된 텍스트 데이터처럼 희소 행렬이라는 특징이 있어서 그렇습니다.

잠재 요인 협업 필터링

잠재 요인 협업 필터링은 사용자-아이템 평점 행렬 데이터사용자 - 잠재 요인 행렬, 잠재요인-아이템 행렬(아이템 - 잠재요인 행렬의 전치 행렬)로 분해하는 것입니다.

RubberDuck
행렬 분해로 생성된 '잠재 요인'이 어떤 것인지는 알 수 없지만, 가령 영화 평점 기반의 사용자-아이템 평점 행렬 데이터라면 영화 장르별 특성 선호도로 가정할 수 있습니다.

1
R[u, i]를 사용자-아이템에 대한 평점이라고 하겠습니다. u는 사용자 id, i는 아이템id입니다. R[1, 1]은 4점, R[1, 4]는 2점입니다.

사용자-잠재 요인 행렬사용자-영화 장르별 선호도 행렬 P라고 가정하고 factor 1을 Action 선호도, factor 2를 Romance 선호도로 설정하겠습니다. P[u, k]에서 u는 사용자 id, k는 잠재 요인 칼럼 id입니다. 다음 그림에서 P[1, 1]는 0.94, P[1, 2]는 0.96 입니다.

아이템-잠재 요인 행렬영화별 장르 요소 행렬 Q라고 가정하고 factor 1는 영화의 Action 요소값, factor 2는 Romance 요소값입니다. Q[i, k]에서 i는 아이템 id, k는 잠재 요인 칼럼 id입니다. (P · Q.T)로 내적을 해서 예측 평점을 계산합니다.

행렬 P의 값은 사용자가 액션, 로맨스를 얼마나 좋아하는지에 대한 수치이고, 행렬 Q는 영화가 액션, 로맨스를 얼마나 포함하고 있는지를 나타낸 수치이다. 가령, 이들을 내적했을 때 사용자가 액션을 좋아하고, 그 영화가 액션을 많이 포함하고 있으면 높은 수치가 나오는 것이다. 그림 6을 처럼 R[1, 1] = (P[1, :] · Q.T[:, 1])을 계산한 값이 나온다. 그림 7과 같이 R[1, 2] = (P[1, :] · Q.T[:, 2])를 통해 예측할 수 있다. 1

사용자-아이템 평점 행렬과 같은 다차원 매트릭스를 저차원의 매트릭스로 분해하는 기법을 행렬 분해(Matrix Factorization)라고 한다. 행렬분해에 대해 자세히 알아보겠습니다.

행렬 분해의 이해

행렬 분해의 대표적인 기법은 SVD(Singular Vector Decomposition), NMF(Non-Negative Matrix Factorization) 등이 있습니다. Factorization(분해)은 우리말로 '인수분해'를 말합니다. x^2 + 5x + 6 = (x+3)(x+2)로 분해하는 것과 다르지 않습니다. 단지, 인수 분해 대상이 행렬일 뿐입니다.


행렬 분해는 그림8과 같이 표현할 수 있습니다. 예를 보며 설명해보겠습니다.


R이 그림9와 같을 때, R은 P와 Q행렬로 분해될 수 있습니다. R_hat의 필드값을 수식으로 나타내면 다음과 같습니다. u는 유저 id, i는 아이템 id입니다.

$$r_{\left(u,\ i\right)}=p_u\cdot q_i^t$$

가령 r(2,4)는 아래와 같습니다. $$r_{\left(2,\ 4\right)}=p_2\cdot q_4^t=2.14\cdot 1.36+0.08\cdot 0.75\ =2.97$$


$$R\approx \hat{R}=P\cdot Q^T$$

R을 P와 Q로의 행렬 분해는 주로 SVD 방식을 사용합니다. 하지만, SVD는 NaN값이 없는 행렬에만 적용할 수 있는데, 이를 확률적 경사 하강법(Stochastic Gradient Descent, SGD)이나 ALS(Alternating Least Sqaures) 방식을 이용해 해결합니다. 다음에서 경사 하강법을 이용한 행렬 분해에 대해 알아보겠습니다.

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

확률적 경사 하강법(SGD)을 이용한 행렬 분해 방법을 요약하자면, P와 Q 행렬로 계산된 예측 R 행렬 값이 실제 R 행렬 값과 가장 최소의 오류를 가지도록 반복적으로 비용 함수를 최적화해서 P와 Q를 유추해내는 것입니다.

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

실제값과 예측값의 오류 최소화와 L2 규제(Regularization)를 고려한 비용 함수식은 다음과 같습니다. $$\min \sum _{\ }^{\ }\left(r_{\left(u,\ i\right)}-p_uq_i^t\right)^2\ +\ \lambda \left(\left|\left|{q_i}\right|\right|^2+\left|\left|{p_u}\right|\right|^2\right)$$ 일반적으로 과적합을 피하기 위해 오류함수에 규제를 반영합니다. 비용함수를 최소화하기 위해 새롭게 업데이트 되는 p_u, q_i는 아래와 같이 계산됩니다.(유도 과정은 책의 범위를 벗어납니다.)
$$\acute{p_u}=p_u+\eta \left(e_{\left(u,\ i\right)}\cdot q_i-\lambda \cdot p_u\right)\\ \acute{q_i}=q_i+\eta \left(e_{\left(u,\ i\right)}\cdot q_i-\lambda \cdot q_i\right)$$

비용 함수식과 업데이트 식의 기호가 의미하는 바는 다음과 같습니다. $$p_u:\ P\ 행렬의\ 사용자\ u행\ 백터\\ $$ $$q_i^t:\ Q\ 행렬의\ 아이템\ i행의\ 전치\ 벡터$$ $$r_{\left(u,\ i\right)}:\ 실제\ R\ 행렬의\ u행,\ i열에\ 위치한\ 값$$ $$\hat{r}_{\left(u,\ i\right)}:\ 예측\ R\ 행렬의\ u행,\ i열에\ 위치한\ 값$$ $$e_{\left(u,\ i\right)\ }:\ u행,\ i열에\ 위치한\ 실제값과\ 예측값의\ 오류, \ r_{\left(u,\ i\right)}-\hat{r}_{\left(u,\ i\right)}$$ $$eta :\ SGD\ 학습률\\ \lambda :\ L2\ 규제\ 계수$$

5장 3절에서는 경사하강법을 비용함수를 최소화하는 방향으로 회귀 계수 업데이트 값 (w1_update, w0_update)을 구하고 이 과정을 계속 반복하는게 핵심 로직이었습니다. 평점 행렬을 경사 하강법을 이용해 행렬 분해하는 것도 이와 유사합니다. L2 규제를 반영해 실제 R 행렬, 예측 R 행렬값의 차이를 최소화하는 방향으로 P와 Q g행렬을 업데이트하는 방식이 SGD기반 행렬 분해입니다.

먼저 예제를 코드로 구현해보겠습니다. 실제 R 행렬을 만들고 P와 Q를 임의로 생성하겠습니다. 잠재 요인의 차원은 3으로 설정하겠습니다.

In [ ]:
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 행렬의 크기를 지정하고 정규 분포를 가진 임의의 값으로 입력합니다.
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과 예측 행렬 R의 오차를 구하는 get_rmse()함수를 만들어보겠습니다. 실제 행렬 R의 Null이 아닌 값의 위치 인덱스만 추출해 예측 함수와의 RMSE 값을 반환합니다.

In [ ]:
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에 null값이 아닌 행렬 인덱스를 추출하고 steps에 SGD를 반복해서 업데이트할 횟수를 적고 learning_rate에 SGD의 학습률, r_lambda에 L2 규제 계수를 적습니다.

In [ ]:
# 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: 0.1617038434758748
### iteration step: 50 rmse: 0.15879774529034177
### iteration step: 100 rmse: 0.15615588093846783
### iteration step: 150 rmse: 0.15374690458219045
### iteration step: 200 rmse: 0.1515441893955807
### iteration step: 250 rmse: 0.14952496790286265
### iteration step: 300 rmse: 0.14766965436961638
### iteration step: 350 rmse: 0.14596130566312013
### iteration step: 400 rmse: 0.1443851885594507
### iteration step: 450 rmse: 0.1429284296892839
### iteration step: 500 rmse: 0.14157973022675385
### iteration step: 550 rmse: 0.14032913173254127
### iteration step: 600 rmse: 0.13916782273477668
### iteration step: 650 rmse: 0.13808797799242636
### iteration step: 700 rmse: 0.1370826241601677
### iteration step: 750 rmse: 0.13614552691929743
### iteration step: 800 rmse: 0.1352710956682408
### iteration step: 850 rmse: 0.13445430265955605
### iteration step: 900 rmse: 0.13369061408652072
### iteration step: 950 rmse: 0.13297593110440087

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

In [ ]:
pred_matrix = np.dot(P, Q.T)
print('예측 행렬:\n', np.round(pred_matrix, 3))
예측 행렬:
 [[3.854 1.828 1.198 2.009 1.717]
 [4.589 4.728 1.047 2.859 1.056]
 [5.583 2.356 2.845 3.767 3.863]
 [4.691 2.027 1.045 2.031 1.642]]

원본 행렬과 비교했을 때 null이 아닌 값은 큰 차이가 나지 않고, null값은 새로운 예측값으로 채워졌습니다.

파이썬 추천 시스템 패키지 - Surprise

Surprise 패키지 소개

사이킷런은 추천 전용 모듈을 제공하지 않지만, Surprise는 파이썬 기반에서 사이킷런과 유사한 API와 프레임워크를 제공합니다.

In [ ]:
!pip install surprise

surprise의 장점은 아래와 같습니다.

  • 사용자, 아이템 기반 최근접 이웃 협업 필터링, SVD, SVD++, NMF 기반 잠재 요인 협업 필터링
  • 사이킷런 핵심 API와 유사한 API 명을 가지고 있습니다. ex) fit, predict ...

Surprise를 이용한 추천 시스템 구축

Surprise에 대한 문서 https://surprise.readthedocs.io/en/stable 에 잘 정리돼 있습니다.

아래 예제는 학습/테스트용 데이터 세트로 분리한 뒤 SVD 행렬 분해를 통한 잠재 요인 협업 필터링을 수행합니다.

In [ ]:
from surprise import SVD
from surprise import Dataset
from surprise import accuracy
from surprise.model_selection import train_test_split

Surprise에서 데이터 로딩은 Dataset 클래스를 이용해서만 가능합니다. 포멧은 로우(Row) 레벨 형태의 데이터만 처리합니다.

MovieLens(무비렌즈) 사이트에서 제공하는 과거 버전 데이터 세트 'ml-100k'(10만 개 평점 데이터)를 받아오겠습니다.

In [ ]:
data = Dataset.load_builtin('ml-100k')
# 수행시 마다 동일하게 데이터를 분할하기 위해 random_state 값 부여
trainset, testset = train_test_split(data, test_size=.25, random_state=0)
Dataset ml-100k could not be found. Do you want to download it? [Y/n] Y
Trying to download dataset from http://files.grouplens.org/datasets/movielens/ml-100k.zip...
Done! Dataset ml-100k has been saved to /root/.surprise_data/ml-100k

최신 MovieLses의 ratings 파일은 ','가 칼럼 분리 문자이지만, 과거 데이터 세트는 '\t'입니다.

SVD로 잠재 요인 협업 필터링을 수행하겠습니다.

In [ ]:
algo = SVD()
algo.fit(trainset)
Out[ ]:
<surprise.prediction_algorithms.matrix_factorization.SVD at 0x7fd1d09b5d90>

Surprise에서 추천을 예측하는 메서드는 test(), predict() 두 개 입니다. test()는 전체 데이터 세트에 대해 추천 데이터 세트를 반환합니다. predict()는 개별 사용자와 영화에 대한 추천 평점을 반환합니다. 각 레코드값은 학습 데이터를 토대로 만든 예측 데이터 입니다.

SVD.test()

In [ ]:
predictions = algo.test(testset)
print('prediction type :', type(predictions), ' size:', len(predictions))
print('predictions 결과의 최초 5개 추출')
predictions[:5]
prediction type : <class 'list'>  size: 25000
predictions 결과의 최초 5개 추출
Out[ ]:
[Prediction(uid='120', iid='282', r_ui=4.0, est=3.75596205398232, details={'was_impossible': False}),
 Prediction(uid='882', iid='291', r_ui=4.0, est=3.7034358427060545, details={'was_impossible': False}),
 Prediction(uid='535', iid='507', r_ui=5.0, est=4.215051223026029, details={'was_impossible': False}),
 Prediction(uid='697', iid='244', r_ui=5.0, est=3.4464854294916587, details={'was_impossible': False}),
 Prediction(uid='751', iid='385', r_ui=4.0, est=3.3884866254256405, details={'was_impossible': False})]

SVD객체.test()호출 결과는 파이썬 리스트이며, 크기는 입력 인자와 같은 크기인 25,000입니다. 리스트 객체 내에는 Prediction 객체를 내부에 가지고 있습니다. 아래와 같은 속성을 튜플 형태로 가지고 있습니다.

  • uid : 개별 사용자 아이디
  • iid : 영화(아이템) 아이디
  • r_ui : 실제 평점
  • est : 추천 예측 평점
  • details : 추천 예측을 할 수 없는 경우 로그 데이터를 남기는 곳, was_impossible: True일 시 예측값을 생성할 수 없는 데이터라는 뜻

다음과 같이 속성을 추출할 수 있습니다.

In [ ]:
[ (pred.uid, pred.iid, pred.est) for pred in predictions[:3]]
Out[ ]:
[('120', '282', 3.75596205398232),
 ('882', '291', 3.7034358427060545),
 ('535', '507', 4.215051223026029)]

SVD.predict()

In [ ]:
uid = str(196)
iid = str(302)
pred = algo.predict(uid, iid)
print(pred)
user: 196        item: 302        r_ui = None   est = 4.19   {'was_impossible': False}

r_ui는 선택적으로 입력할 수 있습니다. 추천 예측 평점과 실제 평점과의 차이를 평가해보겠습니다.

Surprise의 accuracy 모듈은 RMSE, MSE 등의 방법으로 추천 시스템의 성능 평가 정보를 제공합니다.

In [ ]:
accuracy.rmse(predictions)
RMSE: 0.9478
Out[ ]:
0.9478235316590685

Surprise 주요 모듈 소개

Dataset

Surprise는 칼럼 순서가 사용자 아이디, 아이템 아이디, 평점 순인 로우 레벨 데이터 세트만 사용할 수 있습니다. 네 번째 칼럼부터는 아예 로딩을 수행하지 않습니다. 주요 API는 아래와 같습니다.

API 명내용
Dataset.load_builtin(name='ml-100k')무비렌즈 아카이브 FTP 서버에서 무비렌즈 데이터를 내려받습니다. name으로 대상 데이터를 입력합니다.
Dataset.load_from_file(file_path, reader)OS 파일에서 데이터를 로딩할 때 사용합니다. reader로 파일의 포맷을 지정합니다.
Dataset.load_from_df(df, reader)DataFrame에서 데이터를 로딩합니다. reader로 파일의 포맷을 지정합니다.
OS 파일 데이터를 Surprise 데이터 세트로 로딩

OS 파일을 로딩할 때 주의할 점은 데이터 파일에 칼럼명 헤더를 제거해줘야한다는 점입니다.

In [ ]:
import pandas as pd

ratings = pd.read_csv('/content/drive/MyDrive/military/grouplens/ratings.csv')
# ratings_noh.csv 파일로 언로드 시 인덱스와 헤더를 모두 제거한 새로운 파일 생성.
ratings.to_csv('/content/drive/MyDrive/military/grouplens/ratings_noh.csv', index=False, header=False)

Dataset.load_from_file()을 적용하기 전에 Reader 클래스를 이용해 데이터 파일의 파싱 포맷을 정의해야합니다. Reader 클래스는 로딩될 csv 파싱 포멧을 알려줍니다.

Reader 클래스의 생성자에 각 필드의 칼럼명, 칼럼 분리문자, 최소~최대 평점을 입력해 객체를 생성합니다. Reader 객체를 참조해 데이터를 로딩하면 앞의 3개 칼럼만 로딩됩니다.

In [ ]:
from surprise import Reader

reader = Reader(line_format='user item rating timestamp', sep=',', rating_scale=(0.5, 5))
data = Dataset.load_from_file('/content/drive/MyDrive/military/grouplens/ratings_noh.csv', reader=reader)

Surprise Dataset은 무비렌즈 데이터 형식을 따르므로 대부분 일반 OS 파일은 Reader 클래스를 먼저 생성해야합니다. Reader 클래스의 주요 파라미터는 다음과 같습니다.

  • line_format(string): 칼럼을 공백으로 분리해 나열합니다.
  • sep(char): 칼럼을 분리하는 분리자, default는 '\t'
  • rating_scale(tuple, optional): 평점 값의 최소 ~ 최대를 설정합니다. default는 (1, 5)

SVD 행렬 분해 기법으로 데이터를 잠재 요인 협업 필터링 방식으로 추천 예측해보겠습니다.

In [ ]:
trainset, testset = train_test_split(data, test_size=.25, random_state=0)

# 수행 시마다 동일한 결과를 도출하기 위해 random_state 설정
algo = SVD(n_factors=50, random_state=0)

# 학습 데이터 세트로 학습하고 나서 테스트 데이터 세트로 평점 예측 후 RMSE 평가
algo.fit(trainset)
predictions = algo.test(testset)
accuracy.rmse(predictions)
RMSE: 0.8682
Out[ ]:
0.8681952927143516

판다스 DataFrame에서 Surprise 데이터 세트로 로딩

Dataset.load_from_df()를 이용해 Surprise 데이터 세트를 로딩할 수 있습니다. 칼럼 순서는 반드시 지켜야합니다.

Dataset.load_from_df(ratings[['userId', 'movieId', 'rating']], reader)
In [ ]:
import pandas as pd
from surprise import Reader, Dataset

ratings = pd.read_csv('/content/drive/MyDrive/military/grouplens/ratings.csv')
reader = Reader(rating_scale=(0.5, 5.0))

# ratings DataFrame에서 칼럼은 사용자 아이디, 아이템 아이디, 평점 순서를 지켜야 합니다.
data = Dataset.load_from_df(ratings[['userId', 'movieId', 'rating']], reader)
trainset, testset = train_test_split(data, test_size=.25, random_state=0)

algo = SVD(n_factors=50, random_state=0)
algo.fit(trainset)
predictions = algo.test(testset)
accuracy.rmse(predictions)
RMSE: 0.8682
Out[ ]:
0.8681952927143516

Surprise 추천 알고리즘 클래스

Surprise에서 자주 사용되는 추천 알고리즘 클래스는 아래와 같습니다.

클래스 명내용
SVD행렬 분해를 통한 잠재요인 협업 필터링을 위한 SVD 알고리즘.
KNNBasic최근접 이웃 협업 필터링을 위한 KNN 알고리즘.
BaselineOnly사용자 Bias와 아이템 Bias를 감안한 SGD 베이스라인 알고리즘.

이 밖에도 SVD++, NMF 등 다양한 유형의 알고리즘을 수행할 수 있습니다. surprise 문서에서 지원 알고리즘을 참조할 수 있습니다. https://surprise.readthedocs.io/en/stable/prediction_algorithms_package.html

Surprise SVD의 비용 함수는 사용자 베이스 라인(Baseline) 편향성을 감안한 평점 예측에 Regularization을 적용한 것입니다.

$$ 사용자\ 예측\ Rating\ =\ \hat{R}_{ui}=\mu +b_u+b_i+q_i^Tp_u $$

$$Regularization을\ 적용한\ 비용\ 함수:\ \sum _{\ }^{\ }\left(R_{ui}-\ \hat{R}_{ui}\right)^2+\lambda \left(b_i^2+\ b_u^2\ \ +\left|q_i\right|^2+\left|p_u\right|^2\ \right)$$

SVD 클래스의 입력 파라미터는 다음과 같습니다. 파라미터 튜닝 효과는 크게 없습니다.

파라미터 명내용
n_factors잠재 요인 K의 개수, 커질 수록 정확도가 높아지나, 과적합 이슈가 있습니다.
n_epochsSGD(Stochastic Gradient Descent) 수행 시 반복 횟수, default는 20
biased (bool)베이스라인 사용자 편향 적용 여부, default는 True

추천 알고리즘의 예측 성능 벤치마크 결과는 https://surpriselib.com/ 에서 확인할 수 있습니다. 결과를 아래 표에 옮겼습니다.

Movielens 100k RMSE MAE Time
SVD 0.934 0.737 0:00:11
SVD++ 0.92 0.722 0:09:03
NMF 0.963 0.758 0:00:15
Slope One 0.946 0.743 0:00:08
k-NN 0.98 0.774 0:00:10
Centered k-NN 0.951 0.749 0:00:10
k-NN Baseline 0.931 0.733 0:00:12
Co-Clustering 0.963 0.753 0:00:03
Baseline 0.944 0.748 0:00:01
Random 1.514 1.215 0:00:01

SVD ++이 성능이 가장 좋지만, 시간이 오래 걸립니다. K-NN 자체의 성능은 뒤지지만, Baseline을 결합한 경우 성능 수치가 대폭 향상했습니다. Baseline은 각 개인이 평점을 부여하는 성향을 반영해 평점을 계산하는 방식을 말합니다.

베이스라인 평점

우리가 설문을 할 때를 생각해보면, 점수를 후하게 주는 경향이 있는 사람이 있고, 냉철한 평가를 하는 사람도 있습니다. 이러한 개인의 성향을 반영해 아이템 평가에 편향성(Bias) 요소를 반영해 평점을 부여하는 방식이 베이스라인 평점(Baseline Rating)입니다.

베이스라인 평점 공식은 아래와 같습니다.

베이스라인 평점 = 전체 평균 평점 + 사용자 편향 점수 + 아이템 편향 점수

  • 전체 평균 평점 = 모든 사용자의 아이템에 대한 평점을 평균한 값
  • 사용자 편향 점수 = 사용자별 아이템 평점 평균값 - 전체 평균 평점
  • 아이템 편향 점수 = 아이템별 평점 평균값 - 전체 평균 평점

ex)
모든 사용자의 평균 영화 평점: 3.5
사용자 A의 평균 평점: 3.0
어벤저스의 평균 평점: 4.2
3.5+(3.0-3.5)+(4.2-3.5) = 3.7

즉, 사용자 A의 '어벤저스'의 베이스라인 평점은 3.7

교차 검증과 하이퍼 파라미터 튜닝

Surprise는 교차 검증하이퍼 파라미터 튜닝을 위해 사이킷런과 유사한 cross_validate()GridSearchCV 클래스를 제공합니다.

In [ ]:
from surprise.model_selection import cross_validate

# 판다스 DataFrame에서 Surprise 데이터 세트로 데이터 로딩
ratings = pd.read_csv('/content/drive/MyDrive/military/grouplens/ratings.csv') # reading data in pandas df
reader = Reader(rating_scale=(0.5, 5.0))
data = Dataset.load_from_df(ratings[['userId', 'movieId', 'rating']], reader)

algo = SVD(random_state=0)
cross_validate(algo, data, measures=['RMSE', 'MAE'], cv=5, verbose=True)
Evaluating RMSE, MAE of algorithm SVD on 5 split(s).

                  Fold 1  Fold 2  Fold 3  Fold 4  Fold 5  Mean    Std     
RMSE (testset)    0.8791  0.8731  0.8728  0.8741  0.8712  0.8741  0.0027  
MAE (testset)     0.6766  0.6724  0.6695  0.6749  0.6657  0.6718  0.0039  
Fit time          8.68    5.49    5.55    5.35    5.81    6.18    1.26    
Test time         0.36    0.16    0.35    0.17    0.45    0.30    0.11    
Out[ ]:
{'fit_time': (8.684026002883911,
  5.4862060546875,
  5.5502331256866455,
  5.35197639465332,
  5.809110879898071),
 'test_mae': array([0.67655165, 0.67237764, 0.66953309, 0.67485495, 0.66571097]),
 'test_rmse': array([0.87910199, 0.87306881, 0.87284747, 0.8741323 , 0.87116853]),
 'test_time': (0.35562682151794434,
  0.16048669815063477,
  0.34959936141967773,
  0.1684415340423584,
  0.450406551361084)}

GridSearchCV를 이용해 SGD(Stochastic Gradient Descent)의 반복 횟수를 지정하는 n_epochs와 잠재 요인 K의 크기를 지정하는 n_factors를 튜닝하겠습니다.

In [ ]:
from surprise.model_selection import GridSearchCV

# 최적화할 파라미터를 딕셔너리 형태로 지정.
param_grid={'n_epochs':[20, 40, 60], 'n_factors':[50, 100, 200]}

# CV를 3개 폴드 세트로 지정, 성능 평가는 rmse, mse로 수행하도록 GridSearchCV 구성
gs = GridSearchCV(SVD, param_grid, measures=['rmse', 'mae'], cv=3)
gs.fit(data)

# 최고 RMSE Evaluation 점수와 그 때의 하이퍼 파라미터
print(gs.best_score['rmse'])
print(gs.best_params['rmse'])
0.8768116091690633
{'n_epochs': 20, 'n_factors': 50}

Surprise를 이용한 개인화 영화 추천 시스템 구축

Surprise를 이용해 잠재 요인 협업 필터링 기반의 개인화된 영화 추천을 구현해보겠습니다.
즉, 특정 사용자가 아직 평점을 매기지 않은 영화 중에서 개인 취향에 가장 적절한 영화를 추천해보겠습니다.

전체 데이터를 학습시켜 예측할 것인데, Surprise는 데이터 세트를 TrainSet 클래스 객체로 변환하지 않으면 fit()을 통해 학습할 수가 없습니다. 따라서 데이터 세트 전체를 학습 데이터로 사용하려면, DatasetAutoFolds 클래스를 이용하면 됩니다.

DatasetAutoFolds 객체의 build_full_trainset() 메서드를 호출하면 변환할 수 있습니다.

In [ ]:
from surprise.dataset import DatasetAutoFolds

reader = Reader(line_format='user item rating timestamp', sep=',', rating_scale=(0.5, 5))
# DatasetAutoFolds 클래스를 ratings_noh.csv 파일 기반으로 생성.
data_folds = DatasetAutoFolds(ratings_file='/content/drive/MyDrive/military/grouplens/ratings_noh.csv', reader=reader)

# 전체 데이터를 학습 데이터로 생성함.
trainset = data_folds.build_full_trainset()

학습 데이터를 SVD에 학습시킵니다.

In [ ]:
algo = SVD(n_epochs=20, n_factors=50, random_state=0)
algo.fit(trainset)
Out[ ]:
<surprise.prediction_algorithms.matrix_factorization.SVD at 0x7fe363181690>

user 9가 평점을 매기지 않은 영화를 임의로 42번로 정하고 이 영화의 예측 평점을 계산해보겠습니다. 실제로 user 9가 이 영화를 보지 않았는지 확인하고, 영화에 대한 상세 정보를 담은 movies.csv를 로딩한 후 42번 영화에 대해 살펴보겠습니다.

In [ ]:
# 영화에 대한 상세 속성 정보 DataFrame 로딩
movies = pd.read_csv('/content/drive/MyDrive/military/grouplens/movies.csv')

# userId=9의 movieId 데이터를 추출해 movieId=42 데이터가 있는지 확인.
movieIds = ratings[ratings['userId']==9]['movieId']
if movieIds[movieIds==42].count() == 0:
  print('user 9의 영화 42번의 평점이 없음')

print(movies[movies['movieId']==42])
user 9의 영화 42번의 평점이 없음
    movieId                   title              genres
38       42  Dead Presidents (1995)  Action|Crime|Drama

user 9의 영화 42번 추천 예상 평점은 predict() 메서드를 이용하면 됩니다. userId, movieId 모두 문자열로 입력해야합니다.

In [ ]:
uid = str(9)
iid = str(42)
pred = algo.predict(uid, iid, verbose=True)
user: 9          item: 42         r_ui = None   est = 3.13   {'was_impossible': False}

추천 예측 평점은 est값인 3.13입니다. 사용자가 평점을 매기지 않은 영화의 추천 예측 평점을 간단하게 구하는 법을 알았으니 사용자가 평점을 매기지 않은 전체 영화를 추출한 뒤 예측 평점 순으로 영화를 추천해 보겠습니다.

In [ ]:
def get_unseen_surprise(ratings, movies, userId):
  # 입력값으로 들어온 userId에 해당하는 사용자가 평점을 매긴 모든 영화를 리스트로 생성
  seen_movies = ratings[ratings['userId']==userId]['movieId'].tolist()
  
  # 모든 영화의 movieId를 리스트로 생성.
  total_movies = movies['movieId'].tolist()
  unseen_movies = [movie for movie in total_movies if movie not in seen_movies ]
  print('평점 매긴 영화 수:', len(seen_movies), '추천 대상 영화 수:', len(unseen_movies),\
        '전체 영화 수:', len(total_movies))
  
  return unseen_movies

unseen_movies = get_unseen_surprise(ratings, movies, 9)
평점 매긴 영화 수: 46 추천 대상 영화 수: 9696 전체 영화 수: 9742

recomm_movie_by_surprise() 함수를 만들어 추천 알고리즘 객체의 predict() 메서드로 반환된 Prediction 객체를 예측 평점이 높은 순으로 정렬하고 영화 id, 영화 title, est를 추출해 반환합니다.

In [ ]:
def recomm_movie_by_surprise(algo, userId, unseen_movies, top_n=10):
  # 알고리즘 객체의 predict() 메서드를 평점이 없는 영화에 반복 수행한 후 결과를 list 객체로 저장
  predictions = [algo.predict(str(userId), str(movieId)) for movieId in unseen_movies]

  # predictions list 객체는 surprise의 Predictions 객체를 원소로 가지고 있음.
  # [Prediction(uid='9', iid='1', est=3.69), Predictions(uid='9', iid='2', est=2.98),,,,]

  # 이를 est 값으로 적영하기 위해서 아래의 sortkey_est 함수를 정의함.
  def sortkey_est(pred):
    return pred.est

  # sortkey_est() 반환값의 내림 차순으로 정렬 수행하고 top_n개의 최상위 값 추출.
  predictions.sort(key=sortkey_est, reverse=True)
  top_predictions = predictions[:top_n]

  # top_n으로 추출된 영화의 정보 추출. 영화 아이디, 추천 예상 평점, 제목 추출
  top_movie_ids = [int(pred.iid) for pred in top_predictions]
  top_movie_rating = [pred.est for pred in top_predictions]
  top_movie_titles = movies[movies.movieId.isin(top_movie_ids)]['title']

  top_movie_preds =  [(id, title, rating) for id, title, rating in\
                      zip(top_movie_ids, top_movie_titles, top_movie_ids)]
  return top_movie_preds

unseen_movies = get_unseen_surprise(ratings, movies, 9)
top_movie_preds = recomm_movie_by_surprise(algo, 9, unseen_movies, top_n=10)

print('##### Top-10 추천 영화 리스트 #####')
for top_movie in top_movie_preds:
  print(top_movie[1], ":", top_movie[2])
평점 매긴 영화 수: 46 추천 대상 영화 수: 9696 전체 영화 수: 9742
##### Top-10 추천 영화 리스트 #####
Usual Suspects, The (1995) : 858
Star Wars: Episode IV - A New Hope (1977) : 260
Pulp Fiction (1994) : 296
Silence of the Lambs, The (1991) : 1196
Godfather, The (1972) : 50
Streetcar Named Desire, A (1951) : 1104
Star Wars: Episode V - The Empire Strikes Back (1980) : 1210
Star Wars: Episode VI - Return of the Jedi (1983) : 1213
Goodfellas (1990) : 1242
Glory (1989) : 593

Usual Suspect, 대부 등 액션, 서스펜스, 스릴러 영화 등이 추천됐습니다. Surprise 패키지의 API를 통해 손쉽게 파이썬 기반에서 추천 시스템을 구축할 수 있습니다.

정리

추천 시스템에는 콘텐츠 기반 필터링, 협업 필터링이 있습니다.

콘텐츠 기반 필터링은 아이템을 구성하는 여러 컨텐츠(장르, 배우 ..) 중 사용자가 좋아하는 컨텐츠를 필터링해 아이템을 추천하는 방식입니다. 컨텐츠들을 피처 벡터화한 뒤 이들 피처 벡터와 가장 유사한 피처 벡터를 추천하는 것입니다.

협업 필터링은 최근접 이웃 협업 필터링과 잠재 요인 협업 필터링으로 나뉩니다.

  • 최근접 이웃 협업 필터링은 사용자 기반(사용자-사용자)과 아이템 기반(아이템-아이템)으로 나뉩니다. 아이템 기반이 더 자주 사용됩니다.
    • 아이템 기반 최근접 이웃 방식은 특정 아이템과 가장 유사한 다른 아이템을 추천하는 방식입니다. 이 유사도의 기준은 사용자들의 아이템에 대한 평가를 벡터화한 값입니다. 아이템이 행, 사용자가 열로, 레코드값으로 평점이 들어간 아이템-사용자 행렬 데이터 세트를 만들고 아이템 별로 코사인 유사도를 이용해 유사 아이템을 추천하는 방식입니다.
  • 잠재 요인 협업 필터링은 사용자-아이템 평점 행렬을 행렬 분해해 저차원의 사용자-잠재요인, 아이템-잠재요인으로 나눕니다. 이러한 행렬 분해 기법을 경사하강법으로 구현한 예제를 실습해봤습니다.

궁금한 점

  • 행렬 분해 하는 방법이 뭐지? P, Q가 어떻게 만들어지는거지?
  • np.random.noraml에 scale 1./K 알아보기
  • non_zero가 뭐는 그냥 인덱스들을 말하는 건가 list[0]이렇게 해도 되는 건가?
  • 조건문과 for문을 for for if __로도 적을 수 있구나
  • DataFrame.to_csv()
  • top_movie_title = movies[movies.movieId.isin(top_movie_ids)['title']]

출처: 파이썬 머신러닝 완벽 가이드(권철민)
사진 출처: https://velog.io/@yepark/%EB%A8%B8%EC%8B%A0%EB%9F%AC%EB%8B%9D-%EC%99%84%EB%B2%BD%EA%B0%80%EC%9D%B4%EB%93%9C-Chap.9-%EC%B6%94%EC%B2%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C
https://ariz1623.tistory.com/228