[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")

협업 필터링

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

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

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

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

3. 잠재 요인 협업 필터링

잠재 요인 협업 필터링은 사용자-아이템 평점 행렬 속 숨어 있는 잠재 요인을 추출해 추천하는 방식이다.

사용자-아이템 평점 행렬을 사용자-잠재 요인, 잠재 요인-아이템 평점 행렬로 분해한다.

\[R \cong \hat{R} = P \cdot Q.T\]
  • $R$: 사용자-아이템 평정 행렬

  • $\hat{R}$: 잠재 요인을 이용해 예측한 사용자-아이템 평점 행렬

  • $P$: 사용자-잠재 요인 행렬

  • $Q.T$: 잠재 요인-아이템 평점 행렬

잠재 요인은 명확히 정의할 수 없지만 만약 영화 평점 행렬이라면 다음과 같이 분해할 수 있을 것이다.

  • 잠재 요인: 영화에 대한 장르 선호도

  • 사용자-영화 평점 행렬 $\cong$ 사용자-장르 선호도 $\cdot$ 장르 선호도-영화 평점

3.1 행렬 분해

행렬 분해는 SVD, NMF 방식 등을 사용하며, 앞서 SVD, NMF에 대해서 공부하였으니 참고하자 (SVD 링크, NMF 링크).

다만 SVD는 Null 값이 있으면 행렬 분해를 할 수 없다.

Null 값이 존재한다면 확률적 경사 하강법 방식(SGD)을 이용해 SVD를 수행한다.

경사 하강법 역시 기존에 공부한 내용을 참고하자 (경사 하강법 링크).

3.1.1 확률적 경사 하강법 행렬 분해

SGD 행렬 분해 과정

  1. P와 Q를 임의의 값을 가진 행렬로 설정한다.

  2. P와 Q.T를 곱해 예측 R 행렬을 계산하고, 예측 R 행렬과 실제 R 행렬에 해당하는 오류 값을 계산한다.

  3. 이 오류 값을 최소화할 수 있도록 P와 Q 행렬을 업데이트 한다.

  4. 만족할 만한 오류 값을 가질 때까지 2,3번 작업을 반복하면서 P와 Q를 업데이트해 근사한다.

오류 최소화와 과적합 규제를 위해 L2 규제를 고려한 비용 함수식은 다음과 같다.

\[\text{min} \sum \left( r_{(u,i)} - p_{u} q_{i}^{t} \right)^2 + \lambda \left( \Vert q_{i} \Vert^{2} + \Vert p_{u} \Vert^{2} \right)\]

새롭게 업데이트 되는 $p_{(u)}^{\prime}$, $q_{(i)}^{\prime}$는 다음과 같다.

\[p_{(u)}^{\prime} = p_{u} + \eta \left( e_{(u,i)} \cdot q_{i} - \lambda \cdot p_{u} \right)\] \[q_{(i)}^{\prime} = q_{i} + \eta \left( e_{(u,i)} \cdot p_{u} - \lambda \cdot q_{i} \right)\]
  • $r_{(u,i)}$: 실제 R 행렬의 u행, i열 값

  • $p_{(u)}$: P 행렬의 사용자 u행 잠재 요인 벡터

  • $q_{i}^{t}$: Q 행렬의 아이템 i행의 잠재 요인 전치 벡터

  • $\eta$: SGD 학습률

  • $e_{(u,i)}$: u행, i열 실제 행렬 값과 예측 행렬 값의 차이 오류, $r_{(u,i)} - \hat{r}_{(u,i)}$로 계산

  • $\lambda$: L2 규제 계수

이제 SGD를 이용해서 행렬 분해를 직접 구현해보자.

# 원본 행렬 R(4 x 5) 생성
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 ]])

print("원본 행렬 R Shape:", R.shape)
원본 행렬 R Shape: (4, 5)
  • Null 값을 포함한 원본 행렬 R을 생성하였다.
# 잠재요인 차원 K는 3으로 가정
K=3
num_users, num_items = R.shape

# 임의의 P(4 x 3), Q(5 x 3) 생성
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))

print("원본 행렬 P Shape:", P.shape)
print("원본 행렬 Q Shape:", Q.shape)
원본 행렬 P Shape: (4, 3)
원본 행렬 Q Shape: (5, 3)
  • 임의의 P, Q 행렬을 생성하였다.
from sklearn.metrics import mean_squared_error

def get_rmse(R, P, Q, not_nan_index):
    error = 0
    
    # 예측 R 행렬 생성
    full_pred_matrix = P @ Q.T
    
    # Null이 아닌 실제 R 행렬과 예측 행렬
    R_not_null = R[not_nan_index]
    full_pred_matrix_not_null = full_pred_matrix[not_nan_index]
    
    # RMSE 계산
    mse = mean_squared_error(R_not_null, full_pred_matrix_not_null)
    rmse = np.sqrt(mse)
    
    return rmse
  • 실제 R 행렬과 예측 행렬의 RMSE 계산 함수를 생성하였다.

  • Null 값이 아닌 원소만을 이용한다.

# 실제 R 행렬에서 Null이 아닌 index
not_nan_index = np.where(np.isnan(R) == False)

# 반복수, 학습률, L2 규제
steps = 1000
learning_rate = 0.01
r_lambda = 0.01

# SGD 기법으로 P, Q 업데이트
for step in range(steps):
    
    # Null이 아닌 행 index, 열 index, 값
    for u, i, r in zip(not_nan_index[0], not_nan_index[1], R[not_nan_index]):
        # 실제 값과 예측 값의 차이인 오류 값 구함
        r_hat_ui = P[u, :] @ Q[i, :].T
        e_ui = r - r_hat_ui
        
        # SGD 업데이트 공식
        P[u,:] = P[u,:] + learning_rate*(e_ui * Q[i, :] - r_lambda*P[u,:])
        Q[i,:] = Q[i,:] + learning_rate*(e_ui * P[u, :] - r_lambda*Q[i,:])

    rmse = get_rmse(R, P, Q, not_nan_index)
    
    if ( (step + 1)  % 50) == 0 :
        print("### iteration step: ", step + 1 ," rmse: ", np.round(rmse,3))
### iteration step:  50  rmse:  0.506
### iteration step:  100  rmse:  0.159
### iteration step:  150  rmse:  0.076
### iteration step:  200  rmse:  0.044
### iteration step:  250  rmse:  0.029
### iteration step:  300  rmse:  0.023
### iteration step:  350  rmse:  0.02
### iteration step:  400  rmse:  0.018
### iteration step:  450  rmse:  0.017
### iteration step:  500  rmse:  0.017
### iteration step:  550  rmse:  0.017
### iteration step:  600  rmse:  0.017
### iteration step:  650  rmse:  0.017
### iteration step:  700  rmse:  0.017
### iteration step:  750  rmse:  0.017
### iteration step:  800  rmse:  0.017
### iteration step:  850  rmse:  0.017
### iteration step:  900  rmse:  0.016
### iteration step:  950  rmse:  0.016
### iteration step:  1000  rmse:  0.016
  • 앞서 확인한 업데이트 공식을 적용하여 P,Q를 업데이트 하였다.

  • rmse는 반복을 시행할 때 마다 점차 감소하는 것을 확인 가능하다.

pred_matrix = P @ Q.T
print('예측 행렬:')
print(np.round(pred_matrix, 3))

print("-"*35)

print('실제 행렬:')
print(R)
예측 행렬:
[[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 ]]
-----------------------------------
실제 행렬:
[[ 4. nan nan  2. nan]
 [nan  5. nan  3.  1.]
 [nan nan  3.  4.  4.]
 [ 5.  2.  1.  2. nan]]
  • Null이 아닌 값을 비교하면 예측 행렬과 실제 행렬의 값 차이가 크지 않다.

3.2 실습

def matrix_factorization(R, K, steps=200, learning_rate=0.01, r_lambda = 0.01):
    num_users, num_items = R.shape
    
    # 임의의 P(4 x K), Q(5 x K) 생성
    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))

    break_count = 0
    
    # 실제 R 행렬에서 Null이 아닌 index
    not_nan_index = np.where(np.isnan(R) == False)
    
    # SGD 기법으로 P, Q 업데이트
    for step in range(steps):
        
        # Null이 아닌 행 index, 열 index, 값
        for u, i, r in zip(not_nan_index[0], not_nan_index[1], R[not_nan_index]):
            # 실제 값과 예측 값의 차이인 오류 값 구함
            r_hat_ui = P[u, :] @ Q[i, :].T
            e_ui = r - r_hat_ui

            # SGD 업데이트 공식
            P[u,:] = P[u,:] + learning_rate*(e_ui * Q[i, :] - r_lambda*P[u,:])
            Q[i,:] = Q[i,:] + learning_rate*(e_ui * P[u, :] - r_lambda*Q[i,:])

        rmse = get_rmse(R, P, Q, not_nan_index)

        if ( (step + 1)  % 10) == 0 :
            print("### iteration step: ", step + 1 ," rmse: ", np.round(rmse,3))
            
    return P, Q
  • 앞서 SGD를 이용한 행렬 분해를 함수로 정의하였다.
# Grouplens MovieLens 데이터
movies = pd.read_csv('./ml-latest-small/movies.csv')
ratings = pd.read_csv('./ml-latest-small/ratings.csv')

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

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

ratings_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 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN 4.0 NaN
2 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

3 rows × 9719 columns

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

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

  • 앞서 최근접 이웃에서는 아이템 기반 협업 필터링을 위해 전치하였고 NaN은 모두 0으로 대체하였었다.

# 예측 행렬 계산
P, Q = matrix_factorization(ratings_matrix.values, K=50, steps=200, learning_rate=0.01, r_lambda = 0.01)
pred_matrix = P @ Q.T
### iteration step:  10  rmse:  0.759
### iteration step:  20  rmse:  0.531
### iteration step:  30  rmse:  0.383
### iteration step:  40  rmse:  0.302
### iteration step:  50  rmse:  0.256
### iteration step:  60  rmse:  0.227
### iteration step:  70  rmse:  0.208
### iteration step:  80  rmse:  0.195
### iteration step:  90  rmse:  0.186
### iteration step:  100  rmse:  0.178
### iteration step:  110  rmse:  0.172
### iteration step:  120  rmse:  0.167
### iteration step:  130  rmse:  0.163
### iteration step:  140  rmse:  0.16
### iteration step:  150  rmse:  0.157
### iteration step:  160  rmse:  0.155
### iteration step:  170  rmse:  0.153
### iteration step:  180  rmse:  0.151
### iteration step:  190  rmse:  0.149
### iteration step:  200  rmse:  0.148
# 데이터 프레임 생성
ratings_pred_matrix = pd.DataFrame(data=pred_matrix, 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 3.055084 4.092018 3.564130 4.502167 3.981215 1.271694 3.603274 2.333266 5.091749 3.972454 ... 1.402608 4.208382 3.705957 2.720514 2.787331 3.475076 3.253458 2.161087 4.010495 0.859474
2 3.170119 3.657992 3.308707 4.166521 4.311890 1.275469 4.237972 1.900366 3.392859 3.647421 ... 0.973811 3.528264 3.361532 2.672535 2.404456 4.232789 2.911602 1.634576 4.135735 0.725684
3 2.307073 1.658853 1.443538 2.208859 2.229486 0.780760 1.997043 0.924908 2.970700 2.551446 ... 0.520354 1.709494 2.281596 1.782833 1.635173 1.323276 2.887580 1.042618 2.293890 0.396941

3 rows × 9719 columns

# 아직 보지 않은 영화 리스트 함수
def get_unseen_movies(ratings_matrix, userId):
    
    # user_rating: userId의 아이템 평점 정보 (시리즈 형태: title을 index로 가진다.)
    user_rating = ratings_matrix.loc[userId,:]
    
    # user_rating이 notnull인 리스트
    unseen_movie_list = user_rating[ user_rating.isnull() ].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
  • 최근접 이웃에서 사용한 함수를 이용해 아직 보지 않은 영화 중 예측 평점이 높은 영화를 반환한다.

  • 이전엔 user_raiting = 0인 영화 리스트를 만들었으나 여기선 NaN을 0으로 변환하지 않으므로 함수를 일부 수정한다.

# 아직 보지 않은 영화 리스트
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
Rear Window (1954) 5.704612
South Park: Bigger, Longer and Uncut (1999) 5.451100
Rounders (1998) 5.298393
Blade Runner (1982) 5.244951
Roger & Me (1989) 5.191962
Gattaca (1997) 5.183179
Ben-Hur (1959) 5.130463
Rosencrantz and Guildenstern Are Dead (1990) 5.087375
Big Lebowski, The (1998) 5.038690
Star Wars: Episode V - The Empire Strikes Back (1980) 4.989601
  • userId 9번이 아직 보지 않은 영화 중 예측 평점이 가장 높은 상위 10개 영화를 출력했다.

  • 최근접 이웃 기반 협업 필터링과 잠재 요인 협업 필터링 추천 리스트가 많이 다르다.

Leave a comment