본문 바로가기

Euron/정리

[Week18] 09. 추천 시스템 - 실습

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

surprise 패키지 소개

 

추천 시스템은 상업적으로 가치가 크므로 별도의 패키기로 제공된다.

그중 파이썬 기반의 추천 시스템 surprise를 이용해 구현한다.

 

cmd에 pip install scikit-surprise 를 통해 모듈을 설치한다.

surprise 패키지는 다양한 추천 알고리즘을 가지고 있고, 사이킷런의 핵심 API와 유사한 API명으로 작성가능하여 쉽게 추천 시스템을 구축할 수 있다.

 

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

 

Surpruse 문서 확인하기

Welcome to Surprise’ documentation! — Surprise 1 documentation

 

Welcome to Surprise’ documentation! — Surprise 1 documentation

© Copyright 2015, Nicolas Hug Revision c1de6b0e.

surprise.readthedocs.io

 

추천 데이터를 학습용과 테스트용 데이터 세트로 분리한 뒤 SVD 행렬 분해를 통한 잠재 요인 협업 필터링을 수행한다.

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

Surprise에서 데이터 로딩은 Dataset 클래스를 이용해서만 가능하다.

주요 데이터가 로우 레밸 형태로 돼있는 포맷의 데이터만 처리한다.

 

Surprise Dataset 클래스의 load_builtin()은 무비렌즈 사이트에서 제공하는 과거 버전 데이터 세트 데이터를 아카이브 사이트로부터 내려받아 로컬 디렉터리에 저장한 뒤 데이터를 로딩한다.

로딩한 데이터를 학습/테스트 데이터로 분리한다.

data = Dataset.load_builtin('ml-100k')
# 수행 시마다 동일하게 데이터를 분할하기 위해 random_state 값 부여
trainset, testset = train_test_split(data, test_size=.25, random_state=0)

아래 경로에 저장된 것을 확인할 수 있다.

 

Surprise에 사용자-아이템 평점 데이터를 적용할 때, 무비렌즈 사이트에서 내려받은 데이터 파일과 동일하게 로우 레밸의 사용자-아이템 평점 데이터를 그대로 적용해야 한다.

즉, Surprise는 자체적으로 로우 레밸 데이터를 칼럼 레밸 데이터로 변경하므로 원본인 로부 레밸 데이터를 데이터 세트로 적용해야 한다.

 

이제 SVD로 잠재 요인 협업 필터링을 수행한다.

algo = SVD(random_state=0)
algo.fit(trainset)

 

추천 예측은 test()와 predict() 메서드로 할 수 있다.

test()는 사용자-아이템 평점 데이터 세트 전체에 대해서 추천을 예측하는 메서드이다.

predict()는 개별 사용자와 영화에 대한 추천 평점을 반환해준다.

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

test() 메서드의 호출 결과는 파이썬 리스트이며 크기는 입력 데이터 세트의 크기와 같다.

Prediction 객체는 Surprise 패키지에서 제공하는 데이터 타입이며, 개별 사용자 아이디(uid), 영화 아이디(iid), 실제 평점(r_ui) 정보에 기반해 추천 예측 평점(est) 데이터를 튜플 형태로 가진다.

'was_impossible'이 True인 경우 예측값을 생성할 수 없다.

[ (pred.uid, pred.iid, pred.est) for pred in predictions[:3] ]
[('120', '282', 3.5114147666251547),
 ('882', '291', 3.573872419581491),
 ('535', '507', 4.033583485472447)]

 

# 사용자 아이디, 아이템 아이디는 문자열로 입력해야 함. 
uid = str(196)
iid = str(302)
pred = algo.predict(uid, iid)
print(pred)
user: 196        item: 302        r_ui = None   est = 4.49   {'was_impossible': False}

predict()는 개별 사용자의 추천 평점을 예측하며, 이를 위해 인자로 개별 사용자 아이디, 아이템 아이디를 입력한다.

여기서 기존 평점 정보는 선택 사항이며, 사용자/아이템 아이디는 문자열로 입력해야 한다.

 

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

accuracy.rmse(predictions)
RMSE: 0.9467

 

Surprise 주요 모듈 소개

 

Surprise는 로우 레벨 데이터 세트에만 적용할 수 있다. 

또한 데이터의 첫 번째 칼럼을 사용자 아이디, 두 번째 칼럼을 아이템 아이디, 세 번째 칼럼을 평점으로 가정해 데이터를 로딩하고 네 번째 칼럼부터는 로딩을 수행하지 않는다.

이것은 일반 데이터 파일이나 판다스 데이터 칼럼에서도 로딩할 수 있으며, 데이터 세트의 순서는 같다.

 

Dataset API 소개

  • Dataset.load_builtin : 무비렌즈 아카이브 FTP 서버에서 무비렌즈 데이터를 내려 받는다.
  • Dataset.load_from_file : OS 파일에서 데이터를 로딩할 때 사용한다.
  • Dataset.load_from_df : 판다스의 DataFrame에서 데이터를 로딩한다.

OS 파일을 로딩한다. 이때 데이터 파일에 칼럼명을 가지는 헤더 문자열이 있으면 안된다.

따라서 여기서 사용할 데이터 파일은 맨 처음 위치에 칼럼명을 헤더로 가지므로 판다스의 to_csv() 함수를 이용해 칼럼 헤더를 삭제하고 새로운 파일로 저장한다.

MovieLens Latest Datasets | GroupLens

 

MovieLens Latest Datasets

These datasets will change over time, and are not appropriate for reporting research results. We will keep the download links stable for automated downloads. We will not archive or make available p…

grouplens.org

(데이터 파일)

import pandas as pd

ratings = pd.read_csv(r"C:/Users/jain5/Desktop/Euron/Data_Handling/ratings.csv")
# ratings_noh.csv 파일로 unload 시 index 와 header를 모두 제거한 새로운 파일 생성.  
ratings.to_csv('C:/Users/jain5/Desktop/Euron/Data_Handling/ratings_noh.csv', index=False, header=False)

 

해당 데이터 파일에는 칼럼 헤더가 없고, 4개의 칼럼이 콤마로만 분리돼있다. 

Reader 클래스의 생성자에 각 필드의 칼럼명과 칼럼 분리문자, 그리고 최소~최대 평점을 입력해 객체를 생성하고, load_from_file()로 생성된 Reader 객체를 참조해 데이터 파일을 파싱하면서 로딩한다.

from surprise import Reader

reader = Reader(line_format='user item rating timestamp', sep=',', rating_scale=(0.5, 5))
data=Dataset.load_from_file('C:/Users/jain5/Desktop/Euron/Data_Handling/ratings_noh.csv',reader=reader)

 

Reader 클래스의 주요 생성 파라미터

  • line_format(String) : 칼럼을 순서대로 나열. 입력된 문자열을 공백으로 분리해 칼럼으로 인식한다.
  • sep(Char) : 칼럼을 분리하는 분리자. default = '\t'
  • rating_scale(Tuple, optional) : 평점 값의 최소~최대 평점을 설정한다.

이제 SVD 행렬 분해 기법을 이용해 추천을 예측한 후 RMSE로 평가한다.

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

 

 

판다스의 DataFrame에서 Surprise 데이터 세트로 로딩이 가능하다.

마찬가지로 사용자 아이디, 아이템 아이디, 평점 칼럼 순서를 지켜야 한다.

import pandas as pd
from surprise import Reader, Dataset

ratings = pd.read_csv("C:/Users/jain5/Desktop/Euron/Data_Handling/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

 

Surprise 추천 알고리즘 클래스

 

다양한 유형의 알고리즘을 수행할 수 있고, 주요 알고리즘 클래스는 다음과 같다.

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

SVD 클래스의 입력 파라미터는 다음과 같다. 튜닝 효과는 크지 않다.

  • n_factors : 잠재 요인 K의 개수.default=100
  • n_epochs : SGD 수행 시 반복 횟수. default=20
  • biased(bool) : 베이스라인 사용자 편향 적용 여부. default=True

 

베이스라인 평점

 

개인의 성향을 반영해 아이템 평가에 편향성(bias) 요소를 반영하여 평점을 부과하는 것을 베이스라인 평점(Baseline Rating)이라고 한다.

보통 전체 평균 평점 + 사용자 편향 점수 + 아이템 편향 점수 공식으로 계산된다.

예시

 

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

 

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

먼저 cross_validate()의 인자로 알고리즘 객체, 데이터, 성능 평가 방법, 폴드 데이터 세트 개수를 입력한다.

from surprise.model_selection import cross_validate

# 판다스 DataFrame에서 Surprise 데이터 세트로 데이터 로딩
ratings = pd.read_csv("C:/Users/jain5/Desktop/Euron/Data_Handling/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)

cross_validate()는 폴드별 성능 평가 수치와 전체 폴드의 평균 성는 평가 수치를 함께 보여준다.

 

GridSearchCV도 교차 검증을 통함 하이퍼 파라미터 최적화를 수행한다.

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.8774854045628024
{'n_epochs': 20, 'n_factors': 50}

 

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

 

이번에는 데이터를 학습 데이터와 테스트 데이터로 분리하지 않고 전체를 학습 데이터로 사용한다.

다만 Surprise는 데이터 세트를 train_test_split()을 이용해 TrainSet 클래스 객체로 변환 후 fit()을 통해 학습을 수행할 수 있다.

따라서 DatasetAutoFolds 클래스를 이용하여 전체 데이터를 학습 데이터 세트로 만든 후 학습을 수행한다.

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='C:/Users/jain5/Desktop/Euron/Data_Handling/ratings_noh.csv', reader=reader)

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

algo = SVD(n_epochs=20, n_factors=50, random_state=0)
algo.fit(trainset)

 

이제 특정 사용자 userId=9인 사용자가 평점을 매기지 않은 영화의 예측 평점을 계산한다.

# 영화에 대한 상세 속성 정보 DataFrame로딩
movies = pd.read_csv("C:/Users/jain5/Desktop/Euron/Data_Handling/movies.csv")

# userId=9 의 movieId 데이터 추출하여 movieId=42 데이터가 있는지 확인. 
movieIds = ratings[ratings['userId']==9]['movieId']
if movieIds[movieIds==42].count() == 0:
    print('사용자 아이디 9는 영화 아이디 42의 평점 없음')

print(movies[movies['movieId']==42])
사용자 아이디 9는 영화 아이디 42의 평점 없음
    movieId                   title              genres
38       42  Dead Presidents (1995)  Action|Crime|Drama

 

이 movieId 43인 영화에 대해서 userId 9 사용자의 추천 예상 평점은 predict() 메서드를 이용한다.

이때 predict() 메서드 내에 입력할 userId와 movieId 값은 문자열 값이여야 한다.

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}

 

이제 사용자가 평점을 매기지 않은 전체 영화를 추출한 뒤에 예측 평점 순으로 영화를 추천한다.

먼저 사용자가 아직 평점을 매기지 않은 영화 정보를 반환하는 함수를 만든다.

def get_unseen_surprise(ratings, movies, userId):
    #입력값으로 들어온 userId에 해당하는 사용자가 평점을 매긴 모든 영화를 리스트로 생성
    seen_movies = ratings[ratings['userId']== userId]['movieId'].tolist()
    
    # 모든 영화들의 movieId를 리스트로 생성. 
    total_movies = movies['movieId'].tolist()
    
    # 모든 영화들의 movieId중 이미 평점을 매긴 영화의 movieId를 제외하여 리스트로 생성
    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

 

이제 추천 대상 영화수 9696개 중에서 추천 알고리즘 클래스인 SVD를 이용해 높은 예측 평점을 가진 순으로 영화를 추천하는 함수를 만든다.

해당 함수는 학습이 완료된 추천 알고리즘 객체, 추천 대상 사용자 아이디, 추천 대상 영화의 리스트 객체, 그리고 추천 상위 N개 개수를 받는다.

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), Prediction(uid='9', iid='2', est=2.98),,,,]
    # 이를 est 값으로 정렬하기 위해서 아래의 sortkey_est 함수를 정의함.
    # sortkey_est 함수는 list 객체의 sort() 함수의 키 값으로 사용되어 정렬 수행.
    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_rating)]
    
    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) : 4.306302135700814
Star Wars: Episode IV - A New Hope (1977) : 4.281663842987387
Pulp Fiction (1994) : 4.278152632122759
Silence of the Lambs, The (1991) : 4.226073566460876
Godfather, The (1972) : 4.1918097904381995
Streetcar Named Desire, A (1951) : 4.154746591122658
Star Wars: Episode V - The Empire Strikes Back (1980) : 4.122016128534504
Star Wars: Episode VI - Return of the Jedi (1983) : 4.108009609093436
Goodfellas (1990) : 4.083464936588478
Glory (1989) : 4.07887165526957

함수는 추천 대상 영화 모두를 대상으로 추천 알고리즘 객체의 predict() 메서드를 호출하고 그 결과인 Predictijon 객체를 리스트 객체로 저장한다.

그리고 저장된 리스트 내부의 prediction 객체를 예측 평점이 높은 순으로 다시 정렬한 뒤 Top-N 개의 Prediction 객체에서 영화 아이디, 영화 제목, 예측 평점 정보를 추출해 반환한다.

 


# 회고

 

추천 알고리즘 끝~!

내 졸프에서 추천 기능이 들어가는데...이걸 배우기 전에는 트리기반 다중 분류 알고리즘으로 단순하게 예측 후 결과를 반환하는 식으로 만들 생각이였다.

하지만 이렇게 효과적인 알고리즘을 배웠으니...다시 한 번 회의 후 데이터를 정리해서 추천 기능을 제대로 만들어봐야지.