협업필터링 기본
📖 Collaborative Filtering 개념
✅ 기본 개념
협업필터링(CF)는 가장 보편적으로 많이 알려지고 사용되는 추천 알고리즘이다. 기본적인 협업 필터링은 사용자 A에게 추천을 할 때, A와 유사한 취향을 가진 이웃들을 찾고 이 사람들이 좋아하는 상품이나 서비스를 추천하는 방식으로 진행된다.
• 기본 가정
- 사용자로부터 아이템에 대한 명시적/묵시적 평가를 데이터로 구할 수 있다
- 사용자들의 평가 데이터에서 취향이 비슷한 사람을 찾아낼 수 있고, 취향이 비슷한 사람들은 선호 패턴이 비슷하다
• 추천이 적합한 도메인과 그렇지 않은 도메인이 존재한다.
추천이 잘 맞는 도메인은 사람들의 취향이 일관되게 나타나는 도메인이다. 예를 들어, 영화의 경우 한 사람의 취향이 일관되게 유지되는 경우가 많다. 하지만 음식, 의류의 경우 개인의 성향은 물론 상황에 따라서 선호가 자주 바뀌기 때문에 추천이 비교적 어려운 편이다. 또한 고 관여도 제품이거나, 예산에 영향을 많이 받는 경우 추천이 어렵다. 대표적인 예시로 자동차가 있다.
✅ 유사도(similarity) 지표
비슷한 취향을 가진 사용자들을 분류할 때 유사도 지표가 사용된다. 대표적으로는 Euclidean / Cosine / Correlation 이 있다.
📌Correlation coefficient (상관계수)
• 가장 이해하기 쉬운 지표이면서 계산이 간단하다.
• 파이썬 corr() 함수는 주어진 데이터에서 column간 유사도를 계산함
• 평가 데이터가 연속값이고 데이터가 많은 경우 잘 작동함
• 하지만 일반적으로 좋은 성능을 보장하지는 못한다.
📌코사인 유사도
• 협업필터링에서 연속값에 대해서 보편적으로 많이 사용되는 지표.
각 아이템을 차원으로 보고 사용자의 평점을 좌표값으로 본다. 사용자의 평점은 벡터로 나타낼 수 있고, 벡터간의 각도를 코사인 값으로 구해서 유사도를 계산할 수 있다.
• -1(완전 불일치) ~ 1(완전 일치)
• 사이킷런에서는 주어진 데이터에서 row간 유사도를 계산하므로 유의해야 한다
• dimension이 높은 데이터에서 잘 작동, item-based CF에서 잘 작동
📌Tanimoto coefficient
• binary 데이터의 경우 사용함 ( = Jaccard similarity 와 거의 유사)
a = A사용자가 1인 갯수 / b = B사용자가 1인 갯수 / c = A,B 두 사용자 모두 1인 갯수
• 두 사용자가 완전히 같으면 tanimoto coefficient 는 1이 되고, 완전히 다르면 0이 됨
✅ 작동 방식
기본적인 협업필터링은 이웃(neighbor)를 특정 사용자를 제외한 나머지 모두로 본다. 이 경우에 계산 과정은 다음과 같다.
① 모든 사용자간의 평가 유사도를 계산한다. 상관계수, 코사인 등 사용
② 추천 대상과 다른 사용자들의 유사도를 추출한다.
③ 추천 대상이 평가하지 않은 모든 아이템에 대해서, 추천 대상의 예상 평가 값을 구한다.
예상 평가값은 다른 사용자의 해당 아이템에 대한 평가와 그 사용자와의 유사도를 가중평균으로 계산한다.
weighted average 사용 → 사용자 A에 대해서, A의 이웃 사용자들이 평가한 값을 유사도로 가중 평균함
④ 아이템 중에서 예상 평가값이 가장 높은 N개의 아이템을 추천한다.
📚 실습
• 실습을 위한 데이터 셋인 "MovieLens" 데이터를 사용했다.
• MovieLens 100k 데이터는 3가지 파일로 구성
1. 사용자 데이터 : u.user
2. 영화에 대한 데이터 : u.item
3. 영화 평가 : u.data
< 데이터 셋 불러오기 >
|
# 사용자 u.user 파일을 DataFrame으로 읽기 |
|
import os |
|
import pandas as pd |
|
|
|
base_src = 'drive/Mydrive/RecoSys/Data' |
|
u_user_src = os.path.join(base_src, 'u.user') |
|
u_cols = ['user_id', 'age', 'sex', 'occupation', 'zip_code'] |
|
users = pd.read_csv(u_user_src , sep='|', names=u_cols, encoding='latin-1') |
|
|
|
|
|
# u.user 데이터 불러오기 |
|
u_cols = ['user_id', 'age', 'sex', 'occupation', 'zip_code'] |
|
users = pd.read_csv('u.user', sep='|', names=u_cols, encoding='latin-1') |
|
users = users.set_index( 'user_id' ) |
|
users.head() |
# u.items 파일을 DataFrame으로 읽기 |
u_item_src = os.path.join(base_src, 'u.item') |
i_cols = ['movie_id', 'title', 'release date', 'video release date', 'IMDB URL', 'unknown', |
'Action', 'Adventure', 'Animation', 'Children\'s', 'Comedy', 'Crime', 'Documentary', |
'Drama', 'Fantasy', 'Film-Noir', 'Horror', 'Musical', 'Mystery', 'Romance', 'Sci-Fi', |
'Thriller', 'War', 'Western'] |
movies = pd.read_csv(' u_item_src ', sep='|', names=i_cols, encoding='latin-1') |
movies = movie.set_index('movie_id') |
movies.head() |
# u.data 파일을 DataFrame으로 읽기 |
u_data_src = os.path.join(vase_src, 'u.data') |
r_cols = ['user_id', 'movie_id', 'rating', 'timestamp'] |
ratings = pd.read_csv('u.data', sep='\t', names=r_cols, encoding='latin-1') |
ratings.head() |

• 불러온 3개의 데이터셋은 위와 같이 구성되어 있다. 각 데이터셋을 왔다갔다 하면서 작업을 실시하니 혼동하지 않도록 유의.
< 유저 X 아이템 행렬 만들기 >
|
# Rating 데이터를 test, train split 실시(stratified split) |
|
from sklearn.model_selection import train_test_split |
|
x = ratings.copy() |
|
y = ratings['user_id'] |
|
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25, stratify=y, random_state=12) |
|
|
|
#train 셋을 full matrix로 변환 |
|
rating_matrix = x_train.pivot(values='rating', index='user_id', columns='movie_id') |
rating_matrix
• rating_matrix는 사용자 X 아이템(943 X 1631) 형태의 행렬이다.
< 사용자 유사도 행렬 계산 >
|
# 유저 간 유사도 행렬 계산 : consine similarity 사용 |
|
from sklearn.metrics.pairwise import cosine_similarity |
|
|
|
#코사인 유사도 계산 시에는, NaN 값을 허용하지 않으므로 0으로 대체함 |
|
matrix_dummy = rating_matrix.copy().fillna(0) |
|
user_similarity = cosine_similarity(matrix_dummy, matrix_dummy) |
|
user_similarity = pd.DataFrame(user_similarity, index=rating_matrix.index, columns=rating_matrix.index) |
user_similarity
• 코사인 유사도를 이용하여, 유저 pair에 대한 유사도 행렬을 계산한다. 사이킷런의 코사인 유사도 패키지에서는 NaN 값을 허용하지 않으므로 0으로 대체한다. 유저 X 유저 (943 X 943) 형태의 매트릭스를 확인할 수 있다.
< CF 실시 >
|
# 모든 영화의 (movie_id) 가중평균 rating을 계산하는 함수 |
|
|
|
def cf_simple(user_id, movie_id): |
|
if movie_id in rating_matrix: # 해당 movie_id가 rating_matrix에 존재하는지 확인 |
|
|
|
# 현재 사용자와 다른 사용자 간의 similarity 가져오기 |
|
sim_scores = user_similarity[user_id] |
|
|
|
# 현재 영화에 대한 모든 사용자의 rating값 가져오기 |
|
movie_ratings = rating_matrix[movie_id] |
|
|
|
# 현재 영화를 평가하지 않은 사용자의 index 가져오기 |
|
none_rating_idx = movie_ratings[movie_ratings.isnull()].index |
|
|
|
# 현재 영화를 평가하지 않은 사용자의 rating (null) 제거 |
|
movie_ratings = movie_ratings.dropna() |
|
|
|
# 현재 영화를 평가하지 않은 사용자의 similarity값 제거 |
|
sim_scores = sim_scores.drop(none_rating_idx) |
|
|
|
# 현재 영화를 평가한 모든 사용자의 가중평균값 구하기 |
|
mean_rating = np.dot(sim_scores, movie_ratings) / sim_scores.sum() |
|
|
|
else: #해당 movie_id가 없으므로 기본값 3.0을 예측치로 돌려 줌 |
|
mean_rating = 3.0 |
|
|
|
return mean_rating |
|
|
|
|
|
# RMSE 계산 함수 |
|
def RMSE(y_true, y_pred): |
|
return np.sqrt(np.mean((np.array(y_true) - np.array(y_pred))**2)) |
|
|
|
|
|
# score 함수 정의 : 모델을 입력값으로 받음 |
|
def score(model): |
|
id_pairs = zip(x_test['user_id'], x_test['movie_id']) |
|
y_pred = np.array([model(user, movie) for (user, movie) in id_pairs]) |
|
y_true = np.array(x_test['rating']) |
|
return RMSE(y_true, y_pred) |
|
|
|
# 정확도 계산 |
|
score(cf_simple) |
• sim_scores : 943 개 / movie_rating : 943 개
• mean_rating : 유사도를 가중평균을 계산한 예측치
• score() 함수는, CF 모델을 test 셋 데이터에 대해서 적용하는 함수임.
• 정확도를 계산해보면 1.0165 정도로 나타남
< 특정 사용자에게 추천 실시 >
- 한 사용자의 모든 영화에 대한 예측값 계산
- 그중에서 값이 높은 상위 n개만 추출해서 보여줌
|
# 추천을 위한 데이터 다시 로딩 (추천을 위해서는 전체 데이터를 읽어야 함) |
|
r_cols = ['user_id', 'movie_id', 'rating', 'timestamp'] |
|
ratings = pd.read_csv('u.data', names=r_cols, sep='\t',encoding='latin-1') |
|
ratings = ratings.drop('timestamp', axis=1) |
|
rating_matrix = ratings.pivot(values='rating', index='user_id', columns='movie_id') |
|
|
|
# 영화 제목 가져오기 |
|
i_cols = ['movie_id', 'title', 'release date', 'video release date', 'IMDB URL', |
|
'unknown', 'Action', 'Adventure', 'Animation', 'Children\'s', 'Comedy', |
|
'Crime', 'Documentary', 'Drama', 'Fantasy', 'Film-Noir', 'Horror', |
|
'Musical', 'Mystery', 'Romance', 'Sci-Fi', 'Thriller', 'War', 'Western'] |
|
movies = pd.read_csv('u.item', sep='|', names=i_cols, encoding='latin-1') |
|
movies = movies[['movie_id', 'title']] |
|
movies = movies.set_index('movie_id') |
|
|
|
|
|
# Cosine similarity 계산 |
|
rating_matrix = ratings.pivot(values='rating', index='user_id', columns='movie_id') |
|
|
|
from sklearn.metrics.pairwise import cosine_similarity |
|
matrix_dummy = rating_matrix.copy().fillna(0) |
|
user_similarity = cosine_similarity(matrix_dummy, matrix_dummy) |
|
user_similarity = pd.DataFrame(user_similarity, index=rating_matrix.index, columns=rating_matrix.index) |
|
|
|
# 추천하기 |
|
def recommender(user, n_items=10): |
|
# 현재 사용자의 모든 아이템에 대한 예상 평점 계산 |
|
predictions = [] |
|
# 이미 평가한 영화의 인덱스 추출 -> 추천 시 제외해야 함 |
|
rated_index = rating_matrix.loc[user][rating_matrix.loc[user].notnull()].index |
|
# 해당 사용자가 평가하지 않은 영화만 선택 |
|
items = rating_matrix.loc[user].drop(rated_index) |
|
|
|
# 예상평점 계산 |
|
for item in items.index: |
|
predictions.append(cf_simple(user, item)) |
|
|
|
recommendations = pd.Series(data=predictions, index=items.index, dtype=float) |
|
recommendations = recommendations.sort_values(ascending=False)[:n_items] |
|
recommended_items = movies.loc[recommendations.index]['title'] |
|
return recommended_items |
|
|
|
# 영화 추천 함수 부르기 |
|
recommender(2, 10) |
• 앞서 불러온 rating 데이터셋도 사용함
• 실제 추천을 할 때는, train/test 나눌 필요 없이 모든 데이터로 하는게 더 정확하다
• rated_index 에서 해당 사용자가 이미 평가한 영화는 제외함
📖 참고자료 출처
• "Python을 이용한 개인화 추천시스템", 임일, 청람
• "Python을 이용한 개인화 추천시스템", 인프런