영화 추천 알고리즘을 위한 딥러닝 모델 구축 (fastai)

채승헌·2022년 12월 31일
0

딥러닝

목록 보기
1/1

[fastai와 파이토치가 만나 꽃피운 딥러닝] 책의 내용을 개인적인 학습을 위해 정리한 글입니다.

from fastai.collab import *
from fastai.tabular.all import *
import pandas as pd
path = untar_data(URLs.ML_100k)

영화 추천 알고리즘을 위한 딥러닝 모델 구축

ratings = pd.read_csv(path/'u.data', delimiter='\t', header=None,names=['user','movie','rating','timestamp']
                      )
ratings.head()

다음과 같이 table이 나타난다.

indexusermovieratingtimestamp
01962423881250949
11863023891717742
2223771878887116
3244512880606923
41663461886397596

필요한 정보가 있지만, 사람이 쉽게 파악할 수 있는 형태는 아니다. (영화의 ID보다 제목으로 표시하는 것이 파악하기 용이하다)

DataLoaders 만들기

movies = pd.read_csv(path/'u.item', delimiter='|',  encoding='latin-1', usecols=(0,1), names=('movie', 'title'),  header = None)
movies.head()

u.item 테이블에는 각 영화 ID에 대응하는 제목 정보가 있다.

indexmovietitle
01Toy Story (1995)
12GoldenEye (1995)
23Four Rooms (1995)
34Get Shorty (1995)
45Copycat (1995)

두 테이블을 결합하면, 영화 제목으로 사용자별 리뷰 점수를 검색할 수 있다.

ratings=ratings.merge(movies)
ratings.head()
indexusermovieratingtimestamptitle
01962423881250949Kolya (1996)
1632423875747190Kolya (1996)
22262425883888671Kolya (1996)
31542423879138235Kolya (1996)
43062425876503793Kolya (1996)

이렇게 만든 테이블로 DataLoaders 객체를 구축할 수 있다.

DataLoaders 객체인 CollabDataLoaders는 첫번째 열을 사용자, 두번째 열을 영화, 세번째 열을 점수로 사용하는 방식을 따른다.

영화 ID대신 제목으로 항목을 가리키려면,

dls = CollabDataLoaders.from_df(ratings, item_name='title', bs=64)
dls.show_batch()

위 처럼 item_name 인자에 항목으로 사용할 열 이름을 지정해주면 된다.

파이토치는 교차표를 바로 사용할 수 없기 때문에, 영화-사용자 잠재 요소 테이블을 행렬로 표현할 수 있다.

n_users = len(dls.classes['user'])
n_movies = len(dls.classes['title'])
n_factors = 5

user_factors = torch.randn(n_users,n_factors)
movie_factors = torch.randn(n_movies,n_factors)

#영화-사용자 잠재 요소 테이블을 행렬로 표현

특정 영화와 사용자 조합에 대한 결과를 계산하기 위해서는,

  1. 해당 영화-사용자에 해당하는 부분을 찾고 (색인-찾기)
  2. 두 잠재요소 벡터간의 내적을 구하면 된다.

그러나 1번은 딥러닝 모델이 할 줄 아는 연산이 아니다. 딥러닝 모델은

  1. 행렬의 곱셈
  2. 활성화 함수의 적용

과 같은 연산 방법만을 알 뿐이다.

다행히도, 색인-찾기는 행렬의 곱셈으로 표현할 수 있다.

방법은 one-hot encoding 벡터로 색인을 대체하면 된다.

one_hot_3 = one_hot(3, n_users).float()
user_factors.t() @ one_hot_3

# one-hot encoding 벡터로 색인 대체 예제, 색인3에 대한 행렬 곱

이 결과는 행렬의 세 번째 색인에 해당하는 벡터와 같다.

user_factor[3]

임베딩

모든 색인 각각을 one-hot encoding된 벡터를 붙여 행렬을 만들면 원하는 바(여러 색인에 대한 작업을 한번에 수행)를 얻을 수 있지만, 많은 메모리와 시간이 들어간다.

그런데, one-hot encoding된 벡터를 저장해 숫자 1의 위치를 검색할 이유가 없다.

그냥 정수로 배열의 색인을 즉시 찾을 수 있어야한다.

따라서 파이토치 등의 라이브러리는 이를 수행하는 특수한 계층을 제공한다.

  1. 원하는 벡터에 해당하는 색인을 정수로 지정
  2. 원-핫 인코딩된 벡터로 행렬 곱셈을 수행할 때와 같은 방식으로 미분 계산을 수행하는 계층

이를 “임베딩”이라 한다.

사용자-영화 간의 특징은 “장르”와 관련 있을 수 있다. 사용자가 로맨스를 좋아한다면, 로멘스 영화에 더 높은 점수를 줄 가능성이 높다. 이 밖에도 액션이 많은지, 대사가 많은지 등의 요소도 생각해 볼 수 있다.

그렇다면 영화의 각 요소를 표현할 수치를 어떻게 결정해야 할까?

우리도 그 방법을 모르니, 모델에게 학습하도록 한다.

📢 사용자와 영화의 기존 관계를 분석함으로써 모델은 각 특징의 중요도를 스스로 파악할 수 있다.

이것이 ‘임베딩’이다.

각 사용자-영화에 특정 길이(n_factor=5)의 임의로 초기화된 벡터를 부여하여 학습할 수 있는 파라미터를 만든다.

예측과 타깃을 비교해 손실을 계산할 때마다 임베딩 벡터에 대한 손실의 그레디언트를 계산하고, SGD(옵티마이저) 방식으로 임베딩 벡터값들을 갱신한다.

임의로 선택했으므로 처음에는 벡터값들에 아무런 의미가 없지만, 학습이 끝날 무렵에는 유의미하게 바뀔 것이다!


밑바닥부터 만드는 협업 필터링

파이토치 모듈을 새로 만들려면

  1. Module을 상속해야 한다.
  2. 모듈 호출 시 파이토치 클래스 내 정의된 forward 메소드를 호출하고, 전달받은 인잣값을 모두 forward 메소드의 인자로 넘겨준다

모델의 입력은 batch_size * 2 모양의 텐서이다.

첫번째 열에는 사용자 ID가

두번째 열에는 영화 ID가 포함된다.

위 입력은 모두 임베딩 계층으로, 사용자와 영화 사이의 잠재 요소를 나타내는 행렬이다.

class DotProduct(Module):
  def __init__(self, n_users, n_movies, n_factors):
    self.user_factors = Embedding(n_users, n_factors)
    self.movie_factors = Embedding(n_movies, n_factors)

  def forward(self, x):
    users = self.user_factors(x[:,0])
    movies = self.movie_factors(x[:,1])
    return (users * movies).sum(dim=1)
    
# Module 상속 후, forward 메소드에 인잣값들을 모두 넘겨준다.

모델의 구조 정의했고, 파마리터용 행렬을 만들었으므로, 모델을 최적화할 Learner를 만드는 일만 남았다.

이전에 사용했던 cnn_learner 대신 범용적인 Learner 클래스를 사용해, 밑바닥부터 만들어보고자 한다.

model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())

# MSELossFLat은 평균제곱오차를 활용한 loss function이다.

이제 모델을 학습시켜보자.

learn.fit_one_cycle(5, 5e-3)
epochtrain_lossvalid_losstime
01.3108491.30746400:10
11.0667351.10304800:10
20.9875850.99682900:10
30.8743470.90705500:10
40.8101350.88953900.10

아직 loss가 너무 많다.

❓ 모델을 조금 더 개선하려면 무엇을 해야 할까?

  1. 우선 예측 범위가 0~5가 되도록 강제할 수 있다.
    1. sigmoid_range 함수를 이용하면 쉽게 구현할 수 있다.
    2. 범위가 (0,5)일때보다 (0, 5.5)일때 더 결과가 나아진다는 사실을 경험적으로 발견했다.
class DotProduct(Module):
  def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
    self.user_factors = Embedding(n_users, n_factors)
    self.movie_factors = Embedding(n_movies, n_factors)
    self.y_range = y_range

def forward(self, x):
    users = self.user_factors(x[:,0])
    movies = self.movie_factors(x[:,1])
    return sigmoid_range((users*movies).sum(dim=1), *self.y_range)
    
# 개선한 코드, 범위를 추가했고 sigmoid_range를 추가했다.
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)
epoctrain_lossvalid_losstime
00.9831651.00259600:10
10.8562680.91535200:10
20.6680450.87313100:10
30.4901130.87632100:10
40.3591940.88039300:10

검증용 데이터셋의 loss가 조금 감소했다.

아직 더 나아질 수 있다.

내가 놓친 한 가지는, 어떤 사용자는 다른 사용자보다 더 긍정적이거나 부정적인 추천을 하는 경향이 있고, 어떤 영화는 다른 영화보다 더 좋거나 나쁠 수 있다는 사실이다.

가중치에 ‘편향’을 더하면 내가 놓친 부분을 잘 처리할 수 있을 것이다.

class DotProductBias(Module):
  def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
    self.user_factors = Embedding(n_users, n_factors)
    self.user_bias = Embedding(n_users, 1)
    self.movie_factors = Embedding(n_movies, n_factors)
    self.movie_bias = Embedding(n_movies, 1)
    self.y_range = y_range

  def forward(self, x):
    users = self.user_factors(x[:,0])
    movies = self.movie_factors(x[:,1])
    res = (users * movies).sum(dim=1)
    res += self.user_bias(x[:,0]) + self.movie_bias(x[:,1])
    return sigmoid_range(res, *self.y_range)
    
# 모델 구축 후
model = DotProductBias(n_users,n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)

# 학습을 시작해보자
epochtrain_lossvalid_losstime
00.9556100.95508500:10
10.8347970.87429700:11
20.6197430.87539500:10
30.4107670.89738000:10
40.2862400.90483600:10

valid_loss를 살펴보면, 손실 개선이 멈추고 나빠지기 시작했다. 이는 분명한 ‘과적합’ 신호이다.

이를 해결하기 위해, 데이터 증강을 해야하지만, 협업 필터링 문제에 사용할만한 기법은 없다.

별도의 정규화 기법이 필요한데, 이때 도움이 되는 접근법 중 ‘가중치 감쇠’라는 기법이 있다.


가중치 감쇠(weight decay OR L2 regularization)

가중치 감쇠 또는 L2 정규화는 손실 함수에 모든 가중치 제곱의 합을 더하는 것으로 구성된다.

Gradient 게산 시 가중치를 작게 만드는 것을 도와주기 때문이다.

가중치가 너무 커지지 않도록 제한하는 일은 모델의 학습을 방해하지만, 일반화가 더 잘되는(Overfitting 문제 감소) 상태를 만들어낸다.

지금 사용중인 fastai 라이브러리에서는, fit 메소드 사용시 wd 인자를 사용하면 된다.

model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)
epochtrain_lossvalid_losstime
00.9790711.00946200:10
10.9066330.92604000:10
20.7716020.86998200:09
30.6654450.84266100:09
40.5868780.83923100:10

가장 낮은 loss를 기록했다.

weight decay를 사용해, overfitting problem을 해결했다.


나만의 임베딩 모듈 만들기

Embedding 클래스를 사용하지 않고, 이를 대체하는 DotProductBias 클래스를 직접 만들어보고자 한다.

  1. 각 임베딩의 가중치 행렬은 임의로 초기화되어야 한다.
  2. 옵티마이저는 모듈의 parameters 메소드로 모든 파라미터를 가져올 수 있다.
    1. 완전 자동은 X, 모듈의 속성으로 텐서를 추가하더라도 parameters 메소드가 반환하는 파라미터 그룹에 자동으로 포함되지는 않는다.
    2. 텐서를 모듈의 파라미터로 추가하려면 nn.Parameter 클래스로 Wrap해야한다.

파이토치 모듈은 학습 가능한 파라미터에 nn.Parameter를 사용하므로, Wrapper 클래스를 명시적으로 사용할 필요는 없었다.

예제 :

class T(Module):
	def __init__(self) : self.a = nn.Parameter(torch.ones(3))
	
t = T()
L(t.parameters())

#((#1) [Parameter containing:
#tensor([[ 0.3374],
#        [-0.8650],
#        [ 0.0800]], requires_grad=True)]

type(t.a.weight)
#torch.nn.parameter.Parameter

하지만 다음과 같이 명시적으로 초기화된 텐서를 파라미터로 생성할 수도 있다.

예제:

def create_params(size):
  return nn.Parameter(torch.zeros(*size).normal_(0,0.01))

위를 이용하여 DotProductBias를 다시 만들어 볼 계획이다. (Embedding X)

class DotProductBias(Module):
  def __init__(self, n_users, n_movies,n_factors, y_range=(0,5.5)):
    self.user_factors = create_params([n_users, n_factors])
    self.user_bias = create_params([n_users])
    self.movie_factors = create_params([n_movies,n_factors])
    self.movie_bias = create_params([n_movies])
    self.y_range = y_range

  def forward(self, x):
    users = self.user_factors[x[:,0]]
    movies = self.movie_factors[x[:,1]]
    res = (users*movies).sum(dim=1)
    res += self.user_bias[x[:,0]] + self.movie_bias[x[:,1]]
    return sigmoid_range(res, *self.y_range)
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)


# DotProductBias를 이용하여 학습을 진행해도 똑같은 결과를 얻는다.

이번에는 모델이 무엇을 학습했는지를 확인해보고자 한다.


임베딩과 편향의 분석

모델을 통해 사용자에게 영화를 추천 할 수 있게 되었다.

하지만 발견한 파라미터가 무엇인지 확인해보는 일도 꽤 흥미로울 수도 있다.

가장 해석이 쉬운 파라미터는 ‘Bias(편향)’이다.

movie_bias = learn.model.movie_bias.squeeze()
idxs = movie_bias.argsort()[:5]
[dls.classes['title'][i] for i in idxs]

> 실행결과 : 
['Children of the Corn: The Gathering (1996)',
 'Mortal Kombat: Annihilation (1997)',
 'Big Bully (1996)',
 'Cable Guy, The (1996)',
 'Grease 2 (1982)']

출력 결과의 의미는, 사용자가 영화의 잠재 요소와 잘 매칭되더라도, 사용자들이 일반적으로 이 영화들을 좋아하지 않는다는 것을 보여준다.

또한, 사람들이 좋아하지 않는 종류의 영화인지 뿐만 아니라, 사람들이 좋아하지 않는 종류라도 즐길 만한 영화인지도 알 수 있다.

idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
> 실행결과:
['Titanic (1997)',
'Shawshank Redemption, The (1994)',
"Schindler's List (1993)",
'Rear Window (1954)',
'Star Wars (1977)']

역시나 우리에게 친숙한 영화 목록들을 볼 수 있다.


fastai.collab 사용하기

fastai의 collab_learner 함수도 앞과 같은 협업 필터링 모델을 만들고 학습시킬 수 있다.

learn = collab_learner(dls, n_factors=50, y_range=(0,5.5))
learn.fit_one_cycle(5, 5e-3, wd=0.1)

> 훨씬 간단하다

모델을 출력하면 포함된 계층들의 이름을 확인할 수 있다.

learn.model

> EmbeddingDotBias(
  (u_weight): Embedding(944, 50)
  (i_weight): Embedding(1665, 50)
  (u_bias): Embedding(944, 1)
  (i_bias): Embedding(1665, 1)
)

앞에서 수행했던 분석을 모두 재현할 수도 있다.

movie_bias = learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]

> 실행결과:
['Titanic (1997)',
'Shawshank Redemption, The (1994)',
"Schindler's List (1993)",
'Rear Window (1954)',
'Star Wars (1977)']

임베딩 거리

비슷한 영화 두 개가 있다면, 두 영화를 좋아하는 사용자도 매우 비슷할 것이다. 따라서 두 영화의 임베딩 벡터도 매우 유사할 것이다.

이를 일반화하면, 영화간 유사성은 대상 영화를 좋아하는 사용자 간의 유사성으로 정의할 수 있다.

이는 두 영화의 임베딩 벡터 간의 거리가 유사성을 정의 할 수 있음을 의미한다.

movie_factors = learn.model.i_weight.weight
idx = dls.classes['title'].o2i['Star Wars (1977)']
distances = nn.CosineSimilarity(dim=1)(movie_factors, movie_factors[idx][None])
idxs = distances.argsort(descending=True)[1]
dls.classes['title'][idxs]

> 'Empire Strikes Back, The (1980)’

스타워즈와 가장 유사한 영화는 스타워즈 5이다! 신기하다.


협업 필터링을 위한 딥러닝

모델의 구조를 딥러닝으로 전환하는 첫 번째 단계 임베딩 조회 결과를 활성에 연결하는 것이다.

그러면 Activation fucntion에 연결할 수 있는 행렬이 만들어진다. 하지만 임베딩 행렬을 이어 붙이므로(내적없이) 행렬의 크기가 다를 수 있다.

fastai 라이브러리에서는 주어진 데이터를 위한 임베딩 행렬에 권장되는 크기를 반환하는 get_emd_sz 함수를 제공한다.

embs = get_emb_sz(dls)
embs

>[(944, 74), (1665, 102)]

협업 필터링을 위한 딥러닝 모델을 구축하면

class CollabNN(Module):
  def __init__(self, user_sz, item_sz, y_range=(0, 5.5),n_act=100):
    self.user_factors = Embedding(*user_sz)
    self.item_factors = Embedding(*item_sz)
    self.layers = nn.Sequential(
        nn.Linear(user_sz[1]+item_sz[1], n_act),
        nn.ReLU(),
        nn.Linear(n_act, 1)
    )
    self.y_range = y_range

  def forward(self, x):
    embs = self.user_factors(x[:,0]), self.item_factors(x[:,1])
    x = self.layers(torch.cat(embs, dim=1))
    return sigmoid_range(x, *self.y_range)

CollabNN 클래스는 앞서 만든 그것과 같은 방식으로 Embedding 계층을 생성한다.

다른 점은 embs에 명시된 크기를 사용한다는 점밖에 없다.

self.layers는 간단한 신경망과 같다. forward 메소드에서는 임베딩을 적용하고, 적용한 결과를 이어 붙인 다음, 신경망에 전달한다. 마지막에는 앞서 만든 모델과 마찬가지로 sigmoid_range함수를 적용한다.

model = CollabNN(*embs)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.01)
epochtrain_lossvalid_losstime
00.9858570.96124400:03
10.8759930.90916100:03
20.8493700.88319200:03
30.8116880.85838300:03
40.7792630.85917400:03
learn = collab_learner(dls, use_nn=True, y_range=(0,5.5), layers=[100,50])
learn.fit_one_cycle(5, 5e-3, wd=0.1)

collab_learner 호출 시, use_nn = True 인잣값을 넘겨주면, fastai 라이브러리의 fastai.collab의 CollabNN과 같은 모델을 사용할 수 있다(내부적으로 get_emb_sz가 자동으로 호출된다고 함).

또한 layers=[100,50] 인잣값을 넘겨줌으로써, 각각 100과 50이라는 크기의 두 Hidden Layer을 생성할 수 있다.


컴퓨터 영상 처리에서 벗어나 추천 시스템을 다뤄봤다. 이제 뭔가 배우는 느낌이 들어서 좋다.

0개의 댓글