데이터사이언스/PyTorch

PyTorch 4 - 협업필터링

Johnny Yoon 2019. 7. 14. 12:30
728x90
반응형

 

 

 

 
 

 

PyTorch를 활용한 협업필터링

이번 포스팅에서는 행렬분해를 사용한 추천시스템 문제를 풀어보려 한다. 그리고 더 일반적인 산경망 모델을 사용해 해당문제를 풀어보겠다.

MovieLens 데이터셋은 영화 추천에 자주 활용되는 유명한 데이터셋이다. 이 데이터셋에는 영화 추천 시스템 내의 별점 5점 만점의 평점데이터와 자유 텍스트 태깅 정보가 들어있다. 총 9125개의 영화에 대한 100004개의 평점과 1296개의 태그가 기록되어 있다.

영화 데이터는 https://grouplens.org/datasets/movielens/ 에서 내려받을 수 있다.

In [3]:
# wget을 사용해 데이터를 내려받는다
!wget http://files.grouplens.org/datasets/movielens/ml-latest-small.zip
!ls ml-latest-small.zip
 
--2019-07-13 23:55:25--  http://files.grouplens.org/datasets/movielens/ml-latest-small.zip
Resolving files.grouplens.org (files.grouplens.org)... 128.101.65.152
Connecting to files.grouplens.org (files.grouplens.org)|128.101.65.152|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 978202 (955K) [application/zip]
Saving to: ‘ml-latest-small.zip’

ml-latest-small.zip 100%[===================>] 955.28K   339KB/s    in 2.8s    

2019-07-13 23:55:29 (339 KB/s) - ‘ml-latest-small.zip’ saved [978202/978202]

ml-latest-small.zip
In [5]:
# 윈도우에서는 GUI를 사용해 압축을 풀어주면 된다.
!unzip ml-latest-small.zip
!ls ml-latest-small/
 
Archive:  ml-latest-small.zip
   creating: ml-latest-small/
  inflating: ml-latest-small/links.csv  
  inflating: ml-latest-small/tags.csv  
  inflating: ml-latest-small/ratings.csv  
  inflating: ml-latest-small/README.txt  
  inflating: ml-latest-small/movies.csv  
links.csv  movies.csv  ratings.csv  README.txt	tags.csv
 

MovieLens 데이터셋

In [6]:
# 패키지 import
from pathlib import Path
import pandas as pd
import numpy as np
In [139]:
data = pd.read_csv('./ml-latest-small/ratings.csv')
data.head()
Out[139]:
  userId movieId rating timestamp
0 1 1 4.0 964982703
1 1 3 4.0 964981247
2 1 6 4.0 964982224
3 1 47 5.0 964983815
4 1 50 5.0 964982931
 

데이터 인코딩

인코딩은 보통 기다란 벡터에 한개만 표시하는 One-Hot Encoding으로 많이 알려져 있지만, 이런 경우에는 범주형 데이터 (Categorical Data)를 표현하는데 쓰이기도 한다. 우리는 사용자의 id와 영화의 id를 인코딩으로 만들어 줄 것이다.

In [140]:
# 먼저 데이터를 Train과 Validation데이터로 나눈다
np.random.seed(3)
msk = np.random.rand(len(data)) < 0.8
train = data[msk].copy()
val = data[~msk].copy()
print(train.head())
print(val.head())
 
   userId  movieId  rating  timestamp
0       1        1     4.0  964982703
1       1        3     4.0  964981247
2       1        6     4.0  964982224
3       1       47     5.0  964983815
6       1      101     5.0  964980868
    userId  movieId  rating  timestamp
4        1       50     5.0  964982931
5        1       70     3.0  964982400
29       1      543     4.0  964981179
30       1      552     4.0  964982653
32       1      590     4.0  964982546
In [166]:
# 다음은 Pandas의 컬럼을 범주형의 id로 인코드해주는 함수이다
def proc_col(col, train_col=None):
    """ Encodes a pandas column with continous ids. """
    # Unique한 row를 찾는다 즉 사용자 혹은 영화이다
    if train_col is not None:
        uniq = train_col.unique()
    else:
        uniq = col.unique()
    # 사용자/영화를 인덱스와 매핑해준다
    name2idx = {o:i for i,o in enumerate(uniq)}
    # 그리고 그것을 포맷팅해서 리턴한다
    return name2idx, np.array([name2idx.get(x, -1) for x in col]), len(uniq)
In [167]:
# 다음은 실제로 데이터를 인코딩으로 만들어주는 함수이다
# 위에서 정의해준 proc_col을 사용한다
def encode_data(df, train=None):
    """ Encodes rating data with continous user and movie ids. 
    If train is provided, encodes df with the same encoding as train.
    """
    df = df.copy()
    for col_name in ["userId", "movieId"]:
        train_col = None
        if train is not None:
            train_col = train[col_name]
        _,col,_ = proc_col(df[col_name], train_col)
        df[col_name] = col
        df = df[df[col_name] >= 0]
    return df
In [168]:
# Test와 Validation인코딩을 만들어준다
df_train = encode_data(train)
df_val = encode_data(val, train)
print(df_train.head())
print(df_val.head())
 
   userId  movieId  rating  timestamp
0       0        0     4.0  964982703
1       0        1     4.0  964981247
2       0        2     4.0  964982224
3       0        3     5.0  964983815
6       0        4     5.0  964980868
    userId  movieId  rating  timestamp
4        0      388     5.0  964982931
5        0      995     3.0  964982400
29       0      841     4.0  964981179
30       0      567     4.0  964982653
32       0      402     4.0  964982546
 

임베딩 레이어

이제 PyTorch의 nn모듈에 있는 Embedding레이어를 이용해 임베딩을 만들어보겠다.

In [169]:
import torch
import torch.nn as nn
import torch.nn.functional as F
In [170]:
# 아래의 임베딩모델은 최대 10명의 사용자나 3개의 아이템에 대한 관계를 표현한다
# 임베딩의 숫자들은 랜덤으로 초기화 된다
embed = nn.Embedding(10, 3)
# 10명까지의 id이니 6개를 넣어준다
a = torch.LongTensor([[1,2,0,4,5,1]])
embed(a)
Out[170]:
tensor([[[ 1.2046,  0.1480, -0.3302],
         [-1.5962,  0.9431,  0.5272],
         [ 0.8530, -2.0351, -1.3271],
         [ 0.7860, -0.1963,  0.9228],
         [-0.0948, -0.0746, -1.3626],
         [ 1.2046,  0.1480, -0.3302]]], grad_fn=<EmbeddingBackward>)
 

행렬분해 모델

아래는 PyTorch의 딥러닝을 제외한 기능을 사용해 만든 행렬분해 모델이다. 임베딩 두개의 행 즉 벡터들을 각각 내적곱(Dot Product)을 하는 역할을 하는 모델이다.

In [171]:
class MatrixFactorization(nn.Module):
    def __init__(self, num_unsers, num_items, emb_size=100):
        super().__init__()
        self.user_emb = nn.Embedding(num_users, emb_size)
        self.item_emb = nn.Embedding(num_items, emb_size)
        #  정규분포를 사용해 임베딩을 초기화한다
        self.user_emb.weight.data.uniform_(0, 0.05)
        self.item_emb.weight.data.uniform_(0, 0.05)
    
    def forward(self, u, v):
        u = self.user_emb(u)
        v = self.item_emb(v)
        return (u*v).sum(1)
 

위의 모델을 설명하기 위해 모델을 사용하지 않고 모델이 하는 일을 절차적으로 작성해 보겠다. 아래는 학습 즉 Training단계가 없는 절차이다.

In [172]:
# 임의의 테스트 데이터를 만들어본다
# 사용자는 6명 영화는 3개로 이루어져있다
# 사용자와 영화 평점을 각 행렬에 넣고 Pandas DF를 생성
users = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 6, 6]
movies = [0, 1, 1, 2, 0, 1, 0, 3, 0, 3, 3, 1, 3]
ratings = [4, 5, 5, 3, 4, 4, 5, 2, 1, 4, 5, 1, 3]
columns = ['userId', 'movieId', 'rating']

test_df = pd.DataFrame(np.asarray([users, movies, ratings]).T, columns=columns)
test_df
Out[172]:
  userId movieId rating
0 0 0 4
1 0 1 5
2 1 1 5
3 1 2 3
4 2 0 4
5 2 1 4
6 3 0 5
7 3 3 2
8 4 0 1
9 4 3 4
10 5 3 5
11 6 1 1
12 6 3 3
In [173]:
num_users = len(test_df.userId.unique())
num_items = len(test_df.movieId.unique())
emb_size = len(test_df.columns)
print("num_users: {}, num_items: {}, emb_size: {}".format(num_users, num_items, emb_size))

users = torch.LongTensor(test_df.userId.values)
items = torch.LongTensor(test_df.movieId.values)
user_emb = nn.Embedding(num_users, emb_size)
item_emb = nn.Embedding(num_items, emb_size)
print("embeddings: {}, {}".format(user_emb,item_emb))

U = user_emb(users)
V = item_emb(items)

# 임베딩의 각 행을 Dot Product해준다
# 아래의 텐서는 각 행의 결과이니 총 13개가 된다
(U * V).sum(1)
 
num_users: 7, num_items: 4, emb_size: 3
embeddings: Embedding(7, 3), Embedding(4, 3)
Out[173]:
tensor([ 6.0037,  2.3778, -2.3306,  3.0233,  3.3257,  3.0579,  2.6801, -0.3206,
        -0.3813,  0.8415, -0.0288,  3.3900, -0.3483], grad_fn=<SumBackward2>)
 

행렬분해 모델 학습

이제 실제 모델 클래스를 사용해서 행렬분해모델을 학습시킨다. 아래의 예제에서는 임베딩의 사이즈를 100개로 했는데, 이것은 사용자들과 영화들의 관계 즉 평점정보에서 100개의 특성을 뽑아낸다는 것의 의미이다.

In [174]:
# 이제 실제 학습 데이터의 사용자와 영화의 갯수를 구한다.
num_users = len(df_train.userId.unique())
num_items = len(df_train.movieId.unique())

num_users, num_items
Out[174]:
(610, 8998)
In [175]:
# 행렬분해 모델을 만든다
# 임베딩(특성)의 갯수는 100개로 한다
model = MatrixFactorization(num_users, num_items, emb_size=100)
model
Out[175]:
MatrixFactorization(
  (user_emb): Embedding(610, 100)
  (item_emb): Embedding(8998, 100)
)
In [176]:
def validation_loss(model, unsqueeze=False):
    model.eval()
    users = torch.LongTensor(df_val.userId.values)
    items = torch.LongTensor(df_val.movieId.values)
    ratings = torch.FloatTensor(df_val.rating.values)
    if unsqueeze:
        ratings = ratings.unsqueeze(1)
    y_hat = model(users, items)
    loss = F.mse_loss(y_hat, ratings)
    print("validation loss {:.3f}".format(loss.item()))
In [177]:
def train_mf(model, epochs=10, lr=0.01, wd=0.0, unsqueeze=False):
    optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd)
    model.train()
    for i in range(epochs):
        users = torch.LongTensor(df_train.userId.values)
        items = torch.LongTensor(df_train.movieId.values)
        ratings = torch.FloatTensor(df_train.rating.values)
        if unsqueeze:
            ratings = ratings.unsqueeze(1)
        y_hat = model(users, items)
        loss = F.mse_loss(y_hat, ratings)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        print(loss.item()) 
    validation_loss(model, unsqueeze)
In [178]:
train_mf(model, epochs=10, lr=0.1)
 
12.914121627807617
4.942153453826904
2.4287567138671875
3.2427873611450195
0.861353874206543
1.7879230976104736
2.671605110168457
2.181351900100708
1.1014589071273804
0.9215196371078491
validation loss 1.840
In [179]:
train_mf(model, epochs=15, lr=0.01)
 
1.646186351776123
1.0065734386444092
0.7125192284584045
0.6606166362762451
0.7250168323516846
0.8036686778068542
0.8443331718444824
0.8372824788093567
0.7957305312156677
0.7403224110603333
0.6899554133415222
0.6568159461021423
0.6443791389465332
0.648125946521759
0.6587477922439575
validation loss 0.822
In [180]:
train_mf(model, epochs=15, lr=0.01)
 
0.6667324900627136
0.6293866634368896
0.6367530226707458
0.6132015585899353
0.6051971912384033
0.6128155589103699
0.609542191028595
0.5947898030281067
0.5836695432662964
0.5825009942054749
0.5834505558013916
0.5780603289604187
0.5671886801719666
0.5570751428604126
0.5510390996932983
validation loss 0.762

 

참고자료

아래의 원본 소스를 기반으로 번역 및 개인 의견을 덧붙인 코드입니다.

https://github.com/yanneta/pytorch-tutorials

728x90
반응형

'데이터사이언스 > PyTorch' 카테고리의 다른 글

PyTorch 5 - 딥러닝 기반 협업필터링  (0) 2019.07.22
PyTorch 3 - 데이터 로더, 신경망  (0) 2019.07.13
PyTorch 2 - 로지스틱 회귀  (0) 2019.07.11
PyTorch 1 - 텐서  (0) 2019.07.09