본문 바로가기

Data Science/추천시스템과 NLP

[NLP] Surprise library를 활용한 추천시스템 구현하기

반응형

저번 포스팅까지 추천 시스템의 여러가지 종류와 각각의 추천 알고리즘이 어떻게 구현되는지에 대해 자세히 알아보았다. 이번 포스팅에서는 이전까지 소개해왔던 추천 알고리즘을 간편한 API로 제공하는 Surprise 라이브러리에 대해 알아보고 이를 활용해 추천시스템을 구현해보는 시간을 가지려고 한다.

 

우선 Surprise 라이브러리란, Python에 기반하며 Scikit-learn API와 비슷한 형태로 제공을 하여 추천 시스템 구현을 도와주는 편리한 라이브러리이다. Surprise 라이브러리에 대해 더 자세히 알고 싶다면 공식문서를 참고하자.

 

 

(Surprise 추천알고리즘 라이브러리) http://surpriselib.com/

 

Surprise 라이브러리가 구현되는 프로세스주요 메소드 그리고 예시 데이터셋을 사용해 Surprise를 활용한 추천시스템을 직접 구현해보자. 참고로 Surprise 라이브러리가 없다면 설치부터 해야 한다. 필자는 Anaconda3를 사용하므로 이와 같이 설치를 진행했다. 목차는 다음과 같다.

 

1. Surprise 프로세스

2. Surprise 주요 메소드

3. Surprise를 활용한 추천시스템 구현하기

1. Surprise 프로세스

우선 Surprise 라이브러리는 Scikit-learn 에서 제공하는 API 와 비슷한 생김새이다. 구현 프로세스는 일반 머신러닝 프로세스처럼 '데이터 로딩 - 모델 설정 및 학습 - 예측 및 평가' 단계로 진행된다. 하지만 각 단계에서 사용하는 함수나 데이터 포맷 처리 과정에서 차이점이 존재한다. 각 단계별로 어떤 차이점이 있는지 살펴보자.

 

  • 데이터 로딩 : Surprise는 탄생 기원이 '무비 렌즈'라는 공개된 오픈 데이터셋에 기초해서 만들어진 추천시스템이다. 따라서 Surprise를 활용하려는 데이터셋 포맷을 이 '무비 렌즈' 데이터 포맷과 동일하게 해주어야 한다. '무비 렌즈'의 데이터 포맷은 변수가 'user - item - rating' 즉, '사용자 - 아이템 - 평점' 이다. 그렇기 때문에 임의의 데이터셋을 Surprise를 활용하여 추천시스템을 구현하고자 할 때는 반드시 변수명 순서를 '사용자 - 아이템 - 평점' 으로 맞춰 주어야 한다.
  • 모델 설정 및 학습 : 그동안 배웠던 대표적인 추천 알고리즘 종류로서 KNN(최근접 이웃) 기반 또는 SVD(잠재요인 행렬분할) 기반으로 하는 모델을 선정하고 학습시킨다.
  • 예측 및 평가 : 일반 머신러닝 라이브러리와 마찬가지로 Test 데이터로 예측하고 평가한다. 약간의 차이점은 함수 생김새의 차이이다. 이는 다음 목차는 Surprise 주요 메소드에서 알아보기로 하자.

2. Surprise 주요 메소드

사용되는 주요 메소드도 프로세스 별로 설명하려 한다. 데이터 로드하는 단계에서 차이점이 있었던 것처럼 주요 메소드도 데이터 로드하는 함수에 차이점이 존재한다. 이에 포커스를 맞추어서 살펴보자.

2-1. 데이터 로딩

우선 데이터를 로드하는 방법에 3가지가 있다. 첫 번째는 Surprise에 내장된 데이터셋을 로드하는 방법이 있다. 두 번째는 csv 파일 형태를 로드하는 것이고 마지막은 Pandas의 DataFrame 상태로 되어있는 파일의 데이터를 로드하는 방법이다.

 

먼저, Surprise에 내장된 데이터셋을 로드하는 코드이다. 

from surprise import Dataset
from surprise.model_selection import train_test_split

# 내장 데이터인 무비렌즈 데이터 로드하고 학습/테스트 데이터로 분리
data = Dataset.load_builtin('ml-100k')
train, test = train_test_split(data, test_size=0.25,
                              random_state=42)

 

Surprise도 Scikit-learn과 마찬가지로 학습, 테스트 데이터를 분할하는 함수를 편리하게 제공하고 있다. 함수 인자도 동일해서 매우 편리하다고 개인적으로 느꼈다.

 

다음은 구분자가 콤마(,)로 구분되어 있는 일반 csv파일을 데이터셋으로 로드하는 방법이다. 일반 csv 파일은 무비렌즈 데이터의 10만개의 평점이 있는 소량의 데이터를 사용했다. 일반적인 csv파일을 읽어오려면 원본 csv파일에서 index과 header(칼럼명)를 삭제해준 상태로 재 저장한다. 이렇게 하는 이유는 위에서 언급했다시피 Surprise가 인지하고 있는 무비렌즈 데이터셋 포맷은 index와 header가 없는 상태의 포맷이기 때문이다.    

 

import pandas as pd
import os
os.chdir('/Users/younghun/Desktop/gitrepo/data/ml-latest-small')
ratings = pd.read_csv('ratings.csv')
# Surprise 모듈에서 csv파일을 읽어오도록 포맷을 변경해주어야 하기 위해서 따로 저장
# 이 때, index값과 Header(칼럼명)값들 없애주면서 저장시키기
ratings.to_csv('ratings_surprise.csv', index=False, header=False)

 

 

한 가지 추가적으로 해주어야 할 작업이 있다. 바로 Reader라는 함수를 개별적으로 사용하여 csv파일의 포맷을 지정해주어야 한다. 지정해 줄 때는 반드시 '사용자 - 아이템 - 평점' 변수명 순서를 맞추어주어야 한다. 또한 Surprise 라이브러리는 읽어오려는 데이터에 평점(rating) 변수 뒤에 추가적인 여러 변수가 있어도 읽어오지 않는다는 점을 알아두자. 그리고 읽어오려는 파일의 구분자를 지정해주고 rating_scale로 로드할 평점데이터 에서 최소 평점과 최대 평점의 범위를 지정해주자.

 

from surprise import Reader
from surprise.model_selection import train_test_split

reader = Reader(line_format='user item rating timestamp', sep=',',
               rating_scale=(0.5, 5))
data = Dataset.load_from_file('ratings_surprise.csv',reader=reader)
train, test = train_test_split(data, test_size=0.25,
                              random_state=42)

 

마지막으로 Pandas의 데이터프레임 형태의 데이터를 로드하는 방법이다. 이는 일반 csv파일을 읽어올 때와는 달리 구분자를 별도로 지정해줄 필요가없으며 데이터 포맷에는 '사용자-아이템-평점' 이 3가지 변수만 갖고 있는 데이터프레임을 정의만 해주면 된다.

 

import pandas as pd
from surprise import Dataset, Reader
from surprise.model_selection import train_test_split

ratings = pd.read_csv('ratings.csv')
reader = Reader(rating_scale=(0.5, 5))

# load_from_df사용해서 데이터프레임을 데이터셋으로 로드
# 인자에 userid-itemid-ratings 변수들이 포함된 데이터프레임형태로 넣어주면 됨!
data = Dataset.load_from_df(ratings[['userId','movieId','rating']],
                           reader=reader)
train, test = train_test_split(data, test_size=0.25, random_state=42)

2-2. 모델 설정 및 학습

이제 데이터를 로드했으니 모델을 설정해주고 학습을 시켜주자. 이는 Sickit-learn 라이브러리 형태와 매우 동일하다. 데이터 로드하는 방식은 내장 데이터를 로드하는 방식을 사용하겠다. 해당 예시에서는 잠재요인 기반 추천 알고리즘인 SVD를 사용해보자.

 

from surprise import SVD
from surprise import Dataset
from surprise import accuracy
from surprise.model_selection import train_test_split

# 내장 데이터인 무비렌즈 데이터 로드하고 학습/테스트 데이터로 분리
data = Dataset.load_builtin('ml-100k')
train, test = train_test_split(data, test_size=0.25,
                              random_state=42)

# SVD 행렬 분해 알고리즘으로 SVD객체 생성 후 학습 수행
algo = SVD()
algo.fit(train)

 

Surprise도 동일하게 특정 알고리즘을 할당해주고 fit() 함수를 사용해 학습데이터를 학습시키면 된다.

2-3. 예측 및 평가

이제 모델을 학습시켰으니 테스트 데이터로 평점을 예측해보고 평가해보자. Surprise는 예측 및 평가시에는 조금 다른 함수를 사용한다.

예측하는 함수로 test()predict() 를 제공하는데, test() 는 모든 테스트 데이터에 대한 평점 예측을, predict() 는 예측하고 싶은 하나의 데이터의 '사용자'와 '아이템'을 인자로 넣어주어 하나의 예측값만 반환한다. 우선 test()를 사용해 테스트 데이터에 대한 모든 예측값을 살펴보자.

 

prediction = algo.test(test)
print('prediction type: ', type(prediction),
     'size: ', len(prediction))
print()
print('prediction 결과값 5개 미리보기')
print(prediction[:5])

테스트의 모든 데이터에 대한 예측값

 

특이한 점은 리스트 [] 안에 Prediction이라는 객체가 여러개의 요소가 들어있음을 볼 수 있다. 이 때 각 Prediction 객체에서 uid(사용자id), iid(아이템id), r_ui(사용자가 아이템을 평가한 실제 평점), est(예측 평점) 중 특정한 값들만 추출해서 보고 싶을 수 있을 것이다. 이럴 경우 Pandas의 dataframe.column 을 하면 그 데이터프레임의 특정한 칼럼 값들이 추출되는 것처럼 동일한 문법을 사용하면 된다. 다음 코드를 보자.

 

# user id, item id, 예측평점값들만 추출해서 하나의 튜플로 담겨있도록 하기
result = [(pred.uid, pred.iid, pred.est) for pred in prediction[:5]]
print(result)

원하는 값들을 튜플로 담아 추출했다.

 

다음은 predict() 를 사용해 특정한 유저의 특정 아이템에 대한 예측 평점만 보고 싶을 수 있다. 그럴 때는 다음과 같이 수행하자.

 

# 개별 데이터에 대한 예측값 반환을 위해서 predict() 사용
# user id, item id는 문자열로 되어있기 때문에 문자열로 넣어주어야 함!
uid = str(196)
iid = str(302)
# 변수 순서 지켜주어서 넣어주어야 함!
pred = algo.predict(uid, iid)
print(pred)

하나의 데이터에 대해서만 예측하기

 

참고로 위 결과값에서 r_ui = None 이라는 것은 196번 유저가 302번 아이템에 대한 실제 평점이 없다는 뜻이다. 그래서 다른 데이터들을 기반으로 196번 유저는 302번 아이템에 대한 예측평점을 3.70점일 것이라고 예측한 것이다.

 

다음은 평가에 대한 메소드이다. 이는 accuracy 함수를 사용하면 된다. 보통은 metric 함수에 테스트 데이터, 예측 데이터 2가지를 모두 넣었지만 Surprise에서는 예측 데이터만 넣어주면 된다.

 

from surprise import SVD
from surprise import accuracy
from surprise import Dataset, Reader
from surprise.model_selection import train_test_split
import pandas as pd

ratings = pd.read_csv('ratings.csv')
reader = Reader(rating_scale=(0.5, 5))

# load_from_df사용해서 데이터프레임을 데이터셋으로 로드
# 인자에 userid-itemid-ratings 변수들이 포함된 데이터프레임형태로 넣어주면 됨!
data = Dataset.load_from_df(ratings[['userId','movieId','rating']],
                           reader=reader)
train, test = train_test_split(data, test_size=0.25, random_state=42)

algo = SVD(n_factors=50, random_state=42)
algo.fit(train)
predictions = algo.test(test)
accuracy.rmse(predictions)

 

RMSE 결과값

 

추가적으로 Surprise는 교차검증 기능으로 cross_validate와 GridSearchCV를 제공한다.

from surprise.model_selection import cross_validate

# Pandas DF 형태로 데이터 로드
ratings = pd.read_csv('ratings.csv')
reader = Reader(rating_scale=(0.5, 5))

data = Dataset.load_from_df(ratings[['userId','movieId','rating']],
                           reader=reader)

algo = SVD(n_factors=50, random_state=42)
# cross_validate에는 파라미터를 입력시켜 놓은 모델을 인자로 넣어주자!
cross_validate(algo, data, measures=['RMSE','MAE'], cv=5,
              verbose=True)

cross_validate 결과값

 

이번엔 하이퍼파라미터 튜닝도 같이하면서 교차검증을 할 수 있는 GridSearchCV를 사용해보자. GridSearchCV가 cross_validate와 다른 차이점은 모델(알고리즘)을 넣어줄 때 모델을 할당한 변수를 넣지 않고 날것의(?) 모델 자체를 넣어준다는 것이다.

from surprise.model_selection import GridSearchCV

# GridSearch 할 파라미터 사전적으로 정의
param_grid = {'n_epochs':[20,40], 'n_factors':[50, 100,200]}

# GridSearchCV는 cross_validate와는 달리 인자에 알고리즘 자체를 넣어준다!
grid = GridSearchCV(SVD, param_grid=param_grid,
                   measures=['rmse','mae'], cv=3) # measure을 소문자로 해줘야함!
# GridSearchCV로 데이터 학습시키기
grid.fit(data)

# 최고의 score와 그 때의 파라미터 출력
print(grid.best_score['rmse'])
print(grid.best_params['rmse'])

GridSearchCV 결과값

3. Surprise를 활용한 추천시스템 구현하기

위에서 사용했던 무비렌즈 데이터를 사용해 간단한 추천 시스템을 구현해보자. 보기좋게 추천 컨텐츠를 열거해주는 함수를 추가할 뿐 지금까지 해왔던 과정을 종합한 예시이다. 해당 코드를 천천히 살펴보자. 주석을 최대한 사용해 설명했다. 

 

한 가지 추가할 점은 DatasetAutoFolds라는 메소드를 사용한 것인데, 이는 주어진 데이터를 모두 학습 데이터로 사용하기 위한 메소드이다. 즉, train_test_split 하지 않고 모든 데이터를 학습 데이터로 사용해 예측하는 것이다.

 

from surprise.dataset import DatasetAutoFolds
from surprise.dataset import Reader
from surprise import SVD

reader = Reader(line_format='user item rating timestamp', sep=',',
               rating_scale=(0.5, 5))

# DatasetAutoFolds 클래스를 사용해서 개별적으로 생성
# index와 header가 없는 상태로 재생성했던 ratings_surprise.csv파일에 기반
data_folds = DatasetAutoFolds(ratings_file='ratings_surprise.csv',
                             reader=reader)

# 위에서 개별적으로 생성한 csv파일을 학습데이터로 생성
trainset = data_folds.build_full_trainset()
algo = SVD(n_factors=50, n_epochs=20, random_state=42)
algo.fit(trainset)

# 영화에 대한 정보 데이터 로딩
movies = pd.read_csv('movies.csv')
ratings = pd.read_csv('ratings.csv')
# 특정 사용자 9번의 movieId를 추출해서 특정 영화에 대한 평점 있는지 확인
movieIds = ratings[ratings['userId']==9]['movieId']
if movieIds[movieIds==42].count() == 0:
    print('user id=9인 사람은 movie id=42에 대한 평점이 없음')
    
# 영화에 대한 정보 데이터에서 movieId가 42인 영화가 무엇인지 출력
print(movies[movies['movieId']==42])

def get_unseen_surprise(ratings, movies, userId):
    # 특정 유저가 본 movie id들을 리스트로 할당
    seen_movies = ratings[ratings['userId']==userId]['movieId'].tolist()
    # 모든 영화들의 movie id들 리스트로 할당
    total_movies = movies['movieId'].tolist()
    
    # 모든 영화들의 movie id들 중 특정 유저가 본 movie id를 제외한 나머지 추출
    unseen_movies = [movie for movie in total_movies if movie not in seen_movies]
    print(f'특정 {userId}번 유저가 본 영화 수: {len(seen_movies)}\n추천한 영화 개수: {len(unseen_movies)}\n전체 영화수: {len(total_movies)}')
    
    return unseen_movies

def recomm_movie_by_surprise(algo, userId, unseen_movies, top_n=10):
    # 알고리즘 객체의 predict()를 이용해 특정 userId의 평점이 없는 영화들에 대해 평점 예측
    predictions = [algo.predict(str(userId), str(movieId)) for movieId in unseen_movies]
    
    # predictions는 Prediction()으로 하나의 객체로 되어있기 때문에 예측평점(est값)을 기준으로 정렬해야함
    # est값을 반환하는 함수부터 정의. 이것을 이용해 리스트를 정렬하는 sort()인자의 key값에 넣어주자!
    def sortkey_est(pred):
        return pred.est
    
    # sortkey_est함수로 리스트를 정렬하는 sort함수의 key인자에 넣어주자
    # 리스트 sort는 디폴트값이 inplace=True인 것처럼 정렬되어 나온다. reverse=True가 내림차순
    predictions.sort(key=sortkey_est, reverse=True)
    # 상위 n개의 예측값들만 할당
    top_predictions = predictions[:top_n]
    
    # top_predictions에서 movie id, rating, movie title 각 뽑아내기
    top_movie_ids = [int(pred.iid) for pred in top_predictions]
    top_movie_ratings = [pred.est for pred in top_predictions]
    top_movie_titles = movies[movies.movieId.isin(top_movie_ids)]['title']
    # 위 3가지를 튜플로 담기
    # zip함수를 사용해서 각 자료구조(여기선 리스트)의 똑같은 위치에있는 값들을 mapping
    # zip함수는 참고로 여러개의 문자열의 똑같은 위치들끼리 mapping도 가능!
    top_movie_preds = [(ids, rating, title) for ids, rating, title in zip(top_movie_ids, top_movie_ratings, top_movie_titles)]
    
    return top_movie_preds

### 위에서 정의한 함수를 사용해 특정 유저의 추천 영화들 출력해보기
unseen_lst = get_unseen_surprise(ratings, movies, 9)
top_movies_preds = recomm_movie_by_surprise(algo, 9, unseen_lst,
                                           top_n=10)
print()
print('#'*8,'Top-10 추천영화 리스트','#'*8)

# top_movies_preds가 여러가지의 튜플을 담고 있는 리스트이기 때문에 반복문 수행
for top_movie in top_movies_preds:
    print('* 추천 영화 이름: ', top_movie[2])
    print('* 해당 영화의 예측평점: ', top_movie[1])
    print()

 

9번 유저에 대한 상위 10개 추천 영화들은 다음과 같다.

 

9번 유저에게 추천해줄만한 영화들은 무엇일까?

반응형