1. 추천 시스템이란?
추천 시스템이란?
특정 사용자가 좋아해 할 만한 것들을 찾아주는 프로그램
좋은 추천 시스템은 사회적인 이익, 매출과 직접적인 연관이 있다.
추천 시스템들은 어떻게 추천을 할까?
머신 러닝 프로그램: 어떤 작업을 할 때, 경험을 통해, 그 작업에 대한 성능이 향상되는 프로그램
유저의 행동패턴, 선호도를 많이 알 수록 더 좋은 추천시스템이 만들어진다.
추천을 할 때, 유저의 행동 패턴을 파악하여 추천을 더 정확하게 해준다.
즉, 추천 시스템은 다음과 같다.
(1) 유저와 상품의 관계를 표현한 데이터 사용
(2) 유저와 상호 작용이 없었던 상품에 대한 선호도 예측
(3) 선호도가 높게 예측되는 상품들 유저에게 추천
선이수
머신러닝 (행렬 연산, 선형 회귀, 경사 하강법, 정규화, scikit-learn)
데이터 사이언스 입문 코스: 데이터 사이언스 시작하기, Dataframe 다루기
2. 내용 기반 추천
내용 기반 추천이란
상품의 속성, 즉 '어떤' 상품인지를 사용해서 추천한다.
속성과 평점 데이터를 활용해서 추천하고, 영화의 속성을 이용해서 유저의 평점을 예측한다.
머신러닝 모델 학습
좋아요/싫어요 → 분류 알고리즘
1~5값 → 회귀 알고리즘 → 선형 회귀, 다항 회귀
데이터 표현
입력변수는 x, 목표변수 및 유저 평점은 y라고 표현한다.
i 번째 데이터의 j 번째 속성은 아래와 같이 나타낸다.
x^{(i)}_j
x 위에는 괄호 안에 i를 쓰고, 밑에는 j를 쓴다.
다중 선형 회귀 가설 함수
다중 선형 회귀 경사 하강법
다중 선형 회귀 경사 하강법 과정을 거칠 때마다, 손샐을 최대한 빨리 감소 시키는 방향으로 세타 값들이 업데이트 되고, 이것을 충분히 반복하면 결국 손실을 최소에 가깝게 줄일 수 있다. 그러면 우리는 학습 데이터에 잘 맞는 세타 값들, 그러니까 최적의 유저 취향 값들을 찾게 된다.
학습시킨 모델로 추천하기
경사 하강법을 통해 최적의 세타 값들을 찾았는데, 이렇게 찾은 최적의 세타 값들은 각 영화 종류에 대한 선호도를 학습한 값들이다.
세타에 대한 가설함수(h=세타^T*x)로, 경사 하강법으로 구한 세타와 영화 속성을 저장하고 있는 벡터 x를 대입해서 계산한다.
계산한 값은, 각 영화마다 유저가 내릴 평점의 예측값이다.
각 유저마다 이 과정을 반복하면, 유저의 취향 값인 세타 값을 찾을 수 있고 이를 통해 예측 평점을 구할 수 있다.
선형 회귀가 예시가 되었지만, 사실 아무 회귀 알고리즘을 사용해도 문제가 없다.
sklearn으로 유저 평점 예측하기
# 필요한 도구들을 가지고 오는 코드
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
import pandas as pd
import numpy as np
# 유저 평점 + 영화 속성 데이터 경로 정의
MOVIE_DATA_PATH = './data/movie_rating.csv'
# pandas로 데이터 불러 오기
movie_rating_df = pd.read_csv(MOVIE_DATA_PATH)
features =['romance', 'action', 'comedy', 'heart-warming'] # 사용할 속성들 이름
# 입력 변수와 목표 변수 나누기
X = movie_rating_df[features]
y = movie_rating_df[['rating']]
# 입력 변수와 목표 변수들을 각각의 training/test 셋으로 나누기
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=5)
# 코드를 쓰세요.
model = LinearRegression()
model.fit(X_train, y_train)
y_test_predict = model.predict(X_test)
# 실행 코드
y_test_predict
내용 기반 추천 장단점
장점
상품을 추천할 때 다른 유저 데이터가 필요하지 않다.
새롭게 출시한 상품이나, 인기가 없는 상품을 추천할 수 있다.
단점
적합한 속성을 고르는 것이 어렵다. (코미디, 감동, 액션) (장르? 감독? 주연?)
고른 속성 값들이 주관적으로 선정될 수 있다.
유저가 준 데이터를 벗어나는 추천을 할 수 없다.
인기가 많은 상품들을 더 추천해 줄 수 없다.
3. 협업 필터링
협업 필터링이란?
수많은 유저 데이터들이 협업해서 상품을 추천한다.
데이터 표현하기
비슷한 유저 정의하기 1 : 유클리드 거리
numpy 연산
사칙연산 ( - + * / ) : 요소 별로 연산 적용
스칼라 곱 (숫자 * 행렬)
행렬 곱 ( 행렬 @ 행렬)
numpy에서는 비어있는 값/요소를 nan으로 표시한다. 사칙 연산의 앞이나 뒤 행렬 중 하나라도 nan 값이 있으면 결괏값의 원소도 nan으로 표시가 된다.
numpy 기본 함수들
np.sum : 행렬 안에 있는 모든 원소들의 합을 구해주는 함수
np.nansum: np.sum 함수는 원소 중 한 개라도 nan 값이 있으면 항상 np.nan을 리턴하기 때문에, nan 값들을 제외하고 계산을 하고 싶을 때 사용
np.mean: 행렬의 모든 원소들의 평균 값을 계산
np.nanmean: np.mean 함수는 원소 중 한 개라도 nan 값이 있으면 항상 np.nan을 리턴하기 때문에,행렬의 모든 원소들의 평균 값을 계산
numpy 데이터 접근법
행: A[0], A[0,:]
열: A[:,3]
원소: A[0][3]
numpy로 유저 간 거리 구하기
import numpy as np
from math import sqrt
def distance(user_1, user_2):
"""유클리드 거리를 계산해주는 함수"""
# 코드를 쓰세요
return sqrt(np.sum((user_1 - user_2)**2))
# 실행 코드
user_1 = np.array([0, 1, 2, 3, 4, 5])
user_2 = np.array([0, 1, 4, 6, 1, 4])
distance(user_1, user_2)
비슷한 유저 정의하기 2 : 코사인 유사도
선으로 나타내고, 두 선 사이의 각도를 이용해서 유사도를 계산한다.
데이터가 비슷할수록 크고, 다를수록 작다.
평점 데이터 전처리
평점 데이터가 비어있을 때, 처리하는 방법
비어있는 값들은 0으로 가정
최악의 평점으로 계산이 되어 정확도가 낮다는 문제점이 있다.
유저 별 평균 평점으로 계산
합리적으로 유사도를 계산할 수 있다.
Mean Normalization으로 계산
유저 별 평균 평점으로 비어있는 값을 채운 후, 모든 값들에서 평균 평점을 다시 빼준면, 유저마다 평점 평균이 0이 된다.
위 방법을 통해, 모르는 값들을 합리적으로 채워 넣을 수 있다.
그리고, 까다로운 유저들과 유한 유저들에 대한 처리를 해줄 수 있다.
예를 들어 어떤 유저들은 영화가 그저 그러면 평균 평점인 2점을 주고, 별로 마음에 안 들면 0, 마음에 들면 3점을 주며 어떤 유저들은 그저 그러면 평점 4, 마음에 안 들면 3, 마음에 들면 5점을 줄 수도 있다. 모든 유저의 평균 평점을 0으로 맞춰주면 더 싫거나 보통이거나 좋아하는 영화들이 비슷한 값들을 가질 수 있기 때문에 비슷한 유저를 찾을 때 좀 더 직관적으로 찾아낼 수 있다.
유클리드 거리 vs 코사인 유사도
유클리드 거리와 코사인 유사도 가장 직관적인 차이는 코사인 유사도는 각 벡터, 또는 선의 크기가 중요하지 않다는 점이다.
유클리드 거리와 코사인 유사도의 또 다른 차이는 유클리드 거리는 클수록 두 데이터가 다르고, 작을수록 두 데이터가 비슷하다는 의미였고, 코사인 유사도는 클수록 두 데이터가 비슷하고, 작을수록 두 데이터가 다르다는 의미였다.
유저 A와 가장 '비슷한' 유저들을 찾을 때 유클리드 거리는 거리가 가장 작은 유저들을 찾아야 되고, 코사인 유사도는 유사도가 가장 큰 유저들을 찾아내야 된다.
상품 추천하기
이웃들 구하기
import pandas as pd
import numpy as np
from math import sqrt
RATING_DATA_PATH = './data/ratings.csv' # 받아올 평점 데이터 경로 정의
np.set_printoptions(precision=2) # 소수점 둘째 자리까지만 출력
def distance(user_1, user_2):
"""유클리드 거리를 계산해주는 함수"""
return sqrt(np.sum((user_1 - user_2)**2))
def filter_users_without_movie(rating_data, movie_id):
"""movie_id 번째 영화를 평가하지 않은 유저들은 미리 제외해주는 함수"""
return rating_data[~np.isnan(rating_data[:,movie_id])]
def fill_nan_with_user_mean(rating_data):
"""평점 데이터의 빈값들을 각 유저 평균 값으로 체워주는 함수"""
filled_data = np.copy(rating_data) # 평점 데이터를 훼손하지 않기 위해 복사
row_mean = np.nanmean(filled_data, axis=0) # 유저 평균 평점 계산
inds = np.where(np.isnan(filled_data)) # 비어 있는 인덱스들을 구한다
filled_data[inds] = np.take(row_mean, inds[1]) #빈 인덱스를 유저 평점으로 채운다
return filled_data
def get_k_neighbors(user_id, rating_data, k):
"""user_id에 해당하는 유저의 이웃들을 찾아주는 함수"""
distance_data = np.copy(rating_data) # 평점 데이터를 훼손하지 않기 위해 복사
# 마지막에 거리 데이터를 담을 열 추가한다
distance_data = np.append(distance_data, np.zeros((distance_data.shape[0], 1)), axis=1)
# 코드를 쓰세요.
for i in range(len(distance_data)):
row = distance_data[i]
if i == user_id: # 같은 유저면 거리를 무한대로 설정
row[-1] = np.inf
else: # 다른 유저면 마지막 열에 거리 데이터를 저장
row[-1] = distance(distance_data[user_id][:-1], row[:-1])
# 데이터를 거리 열을 기준으로 정렬한다
distance_data = distance_data[np.argsort(distance_data[:, -1])]
# 가장 가까운 k개의 행만 리턴한다 + 마지막(거리) 열은 제외한다
return distance_data[:k, :-1]
# 실행 코드
# 영화 3을 본 유저들 중, 유저 0와 비슷한 유저 5명을 찾는다
rating_data = pd.read_csv(RATING_DATA_PATH, index_col='user_id').values # 평점 데이터를 불러온다
filtered_data = filter_users_without_movie(rating_data, 3) # 3 번째 영화를 보지 않은 유저를 데이터에서 미리 제외시킨다
filled_data = fill_nan_with_user_mean(filtered_data) # 빈값들이 채워진 새로운 행렬을 만든다
user_0_neighbors = get_k_neighbors(0, filled_data, 5) # 유저 0과 비슷한 5개의 유저 데이터를 찾는다
유저 평점 예측하기
import pandas as pd
import numpy as np
from math import sqrt
RATING_DATA_PATH = './data/ratings.csv' # 받아올 평점 데이터 경로 정의
np.set_printoptions(precision=2) # 소수점 둘째 자리까지만 출력
def distance(user_1, user_2):
"""유클리드 거리를 계산해주는 함수"""
return sqrt(np.sum((user_1 - user_2)**2))
def filter_users_without_movie(rating_data, movie_id):
"""movie_id 번째 영화를 평가하지 않은 유저들은 미리 제외해주는 함수"""
return rating_data[~np.isnan(rating_data[:,movie_id])]
def fill_nan_with_user_mean(rating_data):
"""평점 데이터의 빈값들을 각 유저 평균 값으로 체워주는 함수"""
filled_data = np.copy(rating_data) # 평점 데이터를 훼손하지 않기 위해 복사
row_mean = np.nanmean(filled_data, axis=0) # 유저 평균 평점 계산
inds = np.where(np.isnan(filled_data)) # 비어 있는 인덱스들을 구한다
filled_data[inds] = np.take(row_mean, inds[1]) #빈 인덱스를 유저 평점으로 채운다
return filled_data
def get_k_neighbors(user_id, rating_data, k):
"""user_id에 해당하는 유저의 이웃들을 찾아주는 함수"""
distance_data = np.copy(rating_data) # 평점 데이터를 훼손하지 않기 위해 복사
# 마지막에 거리 데이터를 담을 열 추가한다
distance_data = np.append(distance_data, np.zeros((distance_data.shape[0], 1)), axis=1)
# 코드를 쓰세요.
for i in range(len(distance_data)):
row = distance_data[i]
if i == user_id: # 같은 유저면 거리를 무한대로 설정
row[-1] = np.inf
else: # 다른 유저면 마지막 열에 거리 데이터를 저장
row[-1] = distance(distance_data[user_id][:-1], row[:-1])
# 데이터를 거리 열을 기준으로 정렬한다
distance_data = distance_data[np.argsort(distance_data[:, -1])]
# 가장 가까운 k개의 행만 리턴한다 + 마지막(거리) 열은 제외한다
return distance_data[:k, :-1]
def predict_user_rating(rating_data, k, user_id, movie_id,):
"""예측 행렬에 따라 유저의 영화 평점 예측 값 구하기"""
# movie_id 번째 영화를 보지 않은 유저를 데이터에서 미리 제외시킨다
filtered_data = filter_users_without_movie(rating_data, movie_id)
# 빈값들이 채워진 새로운 행렬을 만든다
filled_data = fill_nan_with_user_mean(filtered_data)
# 유저 user_id와 비슷한 k개의 유저 데이터를 찾는다
neighbors = get_k_neighbors(user_id, filled_data, k)
# 코드를 쓰세요
return np.mean(neighbors[:, movie_id])
# 실행 코드
# 평점 데이터를 불러온다
rating_data = pd.read_csv(RATING_DATA_PATH, index_col='user_id').values
# 5개의 이웃들을 써서 유저 0의 영화 3에 대한 예측 평점 구하기
predict_user_rating(rating_data, 5, 0, 3)
상품 기반 협업 필터링
비슷한 '상품'을 써서 예측하는 방법이 상품 기반 협업 필터링이다.
이론상으로 유저와 상품 기반 협업 필터링은 큰 차이가 없다.
하지만 실전에서는 유저보다 상품이 더 단순하기 때문에, 상품 기반 협업 필터링이 더 성능이 좋은 경우가 많다.
협업 필터링 장단점
장점
속성을 찾거나 정할 필요가 없다.
좀 더 폭넓은 상품을 추천할 수 있다.
내용 기반 추천보다 성능이 더 좋게 나오는 경우가 많다.
단점
데이터가 많아야 한다.
새로운 물건을 유저에게 추천해주기 힘들기 때문에, 인기가 많은 소수의 상품이 추천 시스템을 장악할 수 잇다.
어떤 상품이 왜 추천됐는지 정확히 알기 힘들다.
결론
여러 방식을 합쳐서 사용해서 장점을 극대화하고 단점을 보완해야 한다.
4. 행렬인수분해
행렬 인수분해란?
행렬을 다른 행렬의 곱으로 나타낸다.
행렬 인수분해를 했을 때, 비어있는 값을 예측하기 좋아서 성능이 뛰어난 추천 시스템을 만들 수 있다.
하지만, 행렬에 빈값들이 있다면 행렬 인수분해를 하기 어렵다.
곱했을 때 최대한 평점 행렬과 비슷하게 나오는 두 행렬을 구하는 방법을 배울 것이다.
행렬 인수분해 속성 학습
이전에는 영화 속성을 입력 변수, 평점 데이터를 목표 변수로 해서 유저의 취향을 예측했다.
이번에는 영화 속성과 평점 데이터 모두 머신 러닝으로 구할 것이다. (선형 회귀 하강법)
데이터 표현하기
손실 함수
손실함수 J는 각 예측값마다, 제곱 오차를 구하고 실제 평점 데이터가 있는 유저와 영화들에 대해서만 구해서 더해주는 식이다. 즉 평점 데이터에 대한 제곱 오차 합을 계산해 준다.
제곱 오차 합이 작을 수록 지금 취향과 속성 행렬 데이터가 좋다는 뜻이고, 클수록 안좋다는 뜻이다.
손실 함수 구현
import numpy as np
def cost(prediction, R):
"""행렬 인수분해 알고리즘의 손실을 계산해주는 함수"""
# 코드를 쓰세요
return np.nansum((prediction - R)**2)
# 실행 코드
# 예측 값 행렬
prediction = np.array([
[4, 4, 1, 1, 2, 2],
[4, 4, 3, 1, 5, 5],
[2, 2, 1, 1, 3, 4],
[1, 3, 1, 4, 2, 2],
[1, 2, 4, 1, 2, 5],
])
# 실제 값 행렬
R = np.array([
[3, 4, 1, np.nan, 1, 2],
[4, 4, 3, np.nan, 5, 3],
[2, 3, np.nan, 1, 3, 4],
[1, 3, 2, 4, 2, 2],
[1, 2, np.nan, 2, 2, 4],
])
cost(prediction, R)
경사하강법
손실을 가장 빨리 줄이는 방법으로 세타와 엑스 값들을 바꿔주는 방법이다.
손실 함수 볼록도
선형 회귀 손실 함수는 아래로 볼록한 함수이다.
하지만 행렬 인수분해 손실 함수는 여러 개의 극소점들이 있고, 이 중에서 가장 손실이 작은 점이 최소점이다. 임의로 값들을 초기화하고 경사 하강법을 해도 손실을 가장 작게 만드는 세타와 x값들을 찾는다고 할 수는 없다. 찾은 지점이 나머지 극소점일 수도 있다. 하지만 이 방식을 사용하는 이유는, 임의로 설정한 값들보다는 경사 하강법을 사용해서 구한 값들이 항상 성능이 더 좋고, 많은 경우 최소점이 아니라 극소점을 찾아도 성능이 충분히 좋게 나오기 때문이다.
행렬 인수분해 정규화
과적합은 머신 러닝 모델이 training 셋에 너무 딱 맞아서 test 셋에서는 성능이 안 좋게 나오는 경우를 의미한다.
과적합을 막기 위한 방법 중 정규화라는 방법이 있다. 정규화는 손실 함수에 특정 항을 더해서 각 파라미터들의 크기가 너무 커지는 걸 방지하는 방법이다.
다항 회귀의 경우 손실 함수에 L1 또는 L2 정규화 항을 더해서, 이 손실 함수를 최소화시켜주는 세타 값들을 경사 하강법으로 찾는 방식으로 정규화를 적용했다.
행렬 인수분해를 할 때도 모델이 과적합이 되는 문제가 발생할 수 있다. 똑같이 손실 함수에 정규화 항을 더해줌으로써 이 문제를 극복할 수 있습니다. 조금 차이가 있다면, 전에는 변수가 θ 하나였다면, 이제는 θ와 x 두 개라는 것이다. 정규화를 하기 위해서는 이 두 변수들이 커지는 걸 막아야 한다. (식 생략)
유저/속성 행렬 초기화
import numpy as np
# 체점을 위해 numpy에서 임의성 도구들의 결과가 일정하게 나오도록 해준다
np.random.seed(5)
def initialize(R, num_features):
"""임의로 유저 취향과 상품 속성 행렬들을 만들어주는 함수"""
num_users, num_items = R.shape # 유저 데이터 개수와 영화 개수를 변수에 저장
# 코드를 쓰세요.
Theta = np.random.rand(num_users, num_features)
X = np.random.rand(num_features, num_items)
return Theta, X
# 실제 값 행렬
R = np.array([
[3, 4, 1, np.nan, 1, 2],
[4, 4, 3, np.nan, 5, 3],
[2, 3, np.nan, 1, 3, 4],
[1, 3, 2, 4, 2, 2],
[1, 2, np.nan, 2, 2, 4],
])
Theta, X = initialize(R, 2)
Theta, X
행렬 인수분해 경사 하강법 구현
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 체점을 위해 임의성을 사용하는 numpy 도구들의 결과가 일정하게 나오도록 해준다
np.random.seed(5)
RATING_DATA_PATH = './data/ratings.csv' # 데이터 파일 경로 정의
# numpy 출력 옵션 설정
np.set_printoptions(precision=2)
np.set_printoptions(suppress=True)
def predict(Theta, X):
"""유저 취향과 상품 속성을 곱해서 예측 값을 계산하는 함수"""
return Theta @ X
def cost(prediction, R):
"""행렬 인수분해 알고리즘의 손실을 계산해주는 함수"""
return np.nansum((prediction - R)**2)
def initialize(R, num_features):
"""임의로 유저 취향과 상품 속성 행렬들을 만들어주는 함수"""
num_users, num_items = R.shape
Theta = np.random.rand(num_users, num_features)
X = np.random.rand(num_features, num_items)
return Theta, X
def gradient_descent(R, Theta, X, iteration, alpha, lambda_):
"""행렬 인수분해 경사 하강 함수"""
num_user, num_items = R.shape
num_features = len(X)
costs = []
for _ in range(iteration):
prediction = predict(Theta, X)
error = prediction - R
costs.append(cost(prediction, R))
for i in range(num_user):
for j in range(num_items):
if not np.isnan(R[i][j]):
for k in range(num_features):
# 아래 코드를 채워 넣으세요.
Theta[i][k] -= alpha * (np.nansum(error[i, :]*X[k, :]) + lambda_*Theta[i][k])
X[k][j] -= alpha * (np.nansum(error[:, j]*Theta[:, k]) + lambda_*X[k][j])
return Theta, X, costs
#----------------------실행(채점) 코드----------------------
# 평점 데이터를 가지고 온다
ratings_df = pd.read_csv(RATING_DATA_PATH, index_col='user_id')
# 평점 데이터에 mean normalization을 적용한다
for row in ratings_df.values:
row -= np.nanmean(row)
R = ratings_df.values
Theta, X = initialize(R, 5) # 행렬들 초기화
Theta, X, costs = gradient_descent(R, Theta, X, 200, 0.001, 0.01) # 경사 하강
# 손실이 줄어드는 걸 시각화 하는 코드 (디버깅에 도움이 됨)
# plt.plot(costs)
Theta, X
결과 해석
학습을 했지만, 학습된 데이터가 진짜 로맨스와 코미디에 대한 데이터인지 알 수 없다.
그러므로, 각 데이터에 의미를 부여하지 않고 작은 임의의 값들로 초기화한다.
어떤 속성을 쓸지는 정하지 않지만, 몇 개를 쓸지는 정해야 한다.
교차 검증과 그리드 서치를 사용해서 성능이 가장 좋은 개수로 정한다.
행렬 인수분해 마무리
기존 여러 다른 방식들보다 더 성능이 좋다.
행렬 인수분해도 협업 필터링의 한 종류이다. 각 유저 취향과 영화 속성이 모든 유저 데이터를 사용해서 학습되기 때문이다.
최적의 성능을 내기 위해서는 모든 방식을 활용하는 것이 좋다.
'🚀 AI' 카테고리의 다른 글
추천 알고리즘의 유형과 그 동작원리 (0) | 2021.05.05 |
---|