Python/Pytorch

[Pytorch] Neural Graph Collaborative Filtering (NGCF) 구현하기

언킴 2023. 1. 1. 11:01
반응형

Contents

     

    Neural Graph Collaborative Filtering (NGCF)는 기존의 협업 필터링(Collaborative Filtering)이 고차-연결성(High-Order Connectivity)을 고려하지 못한다는 단점을 극복하고자 그래프를 협업 필터링에 도입 시킨 기법이다. 전통적인 협업 필터링은 user-item interaction만을 고려하게 된다. 대표적인 Neural Collaborative Filtering (NCF)를 생각해보면 사용자 ID와 제품 ID를 내적하거나 연결(Concatenate)하여 사용자의 제품에 대한 구매 확률을 도출한다. 그렇기 때문에 제품과 다른 사용자 간의 관계 즉, 고차-연결성을 고려하지 못한다는 것이다. 예를 들어 $u_1 \leftarrow i_2 \leftarrow u_2 \leftarrow i_4$라는 그래프 상의 경로가 있을 때 그래프 구조에서는 이 경로를 모두 고려해 이웃 사용자들로부터 협력 신호(Collaborative Signal)을 제공 받아 $u_1$에게 $i_4$를 추천할 정보를 얻을 수 있지만, 기존의 협업 필터링에서는 어렵다는 것을 언급하고 있다. 이번 글에서는 NGCF의 코드를 리뷰하기 떄문에 논문의 자세한 내용은 여기를 참고하길 바란다.

     

     

    Embedding Layer

    먼저, 모델의 입력으로 사용하기 위해서는 사용자 ID와 제품 ID를 아래와 같이 임베딩(Embedding)하여야 한다. 

    \[ E = [ e_{u_1}, \cdots, e_{u_N}, e_{i_1}, \cdots, e_{i_M} ] \]

    $e_u \in \mathbb{R}^d, \\ (e_i \in \mathbb{R}^d )$에서 $d$는 임베딩 크기를 의미하고, $e_u$와 $e_i$는 각각 사용자와 제품의 표현(Representation) 혹은 임베딩 벡터라고 부른다. 코드에서는 룩-업(Look-up) 테이블을 만들어, 인덱스에 위치하는 값을 추출한다. 

    import torch
    from torch import nn
    
    class NGCF(nn.Module):
        def __init__(self, args, matrix):
            super(NGCF, self).__init__()
            self.num_users = args.num_users 
            self.num_items = args.num_items 
            self.latent_dim = args.latent_dim 
            self.device = args.device
    
            self.user_emb = nn.Embedding(self.num_users, self.latent_dim)
            self.item_emb = nn.Embedding(self.num_items, self.latent_dim)
    
            self.num_layers = args.num_layers

     

    GNN Layer

    그래프에서는 Message Passing 단계가 필요하다. 해당 논문에서는 아래와 같은 수식을 통해 계산하고 있다. 

    \[ m_{u \leftarrow i} = \frac{1}{\sqrt{|\mathcal{N}_u| | \mathcal{N}_{i} |}} (W_1 e_i + W_2 (e_i \odot e_u)) \]

    $|\mathcal{N}_u|$과 $|\mathcal{N}_i|$는 각각 사용자 노드와 제품 노드의 수를 의미한다. 추천 시스템에서 노드(Node)는 사용자와 제품을 의미하고, 간선(Edge)은 구매 여부 혹은 평점을 나타낸다. 위 수식을 행렬 형태로 변환하면 아래와 같이 표현할 수 있다.

    \[ E^{(l)} = \text{LeakyReLU} ( (\mathcal{L} + I)E^{(l-1)}W^{(l)}_1 + \mathcal{L}E^{(l-1)} \odot E^{(l-1)}W^{(l)}_2 ) \]

    코드를 실행시킬 때에는 행렬 형태로 변환하고 계산하기 때문에 계산의 효율성 문제로 행렬로 변환하고 사용한다.

    class GNNLayer(nn.Module):
        def __init__(self, in_feats, out_feats):
            super(GNNLayer, self).__init__()
            self.in_feats = in_feats
            self.out_feats = out_feats 
    
            self.W1 = nn.Linear(in_feats, out_feats)
            self.W2 = nn.Linear(in_feats, out_feats)
    
        def forward(self, L, SelfLoop, feats):
            # (L+I)EW_1
            sf_L = L + SelfLoop
            L = L.cuda()
            sf_L = sf_L.cuda()
            sf_E = torch.sparse.mm(sf_L, feats)
            left_part = self.W1(sf_E) # left part
    
            # EL odot EW_2, odot indicates element-wise product 
            LE = torch.sparse.mm(L, feats)
            E = torch.mul(LE, feats)
            right_part = self.W2(E)
    
            return left_part + right_part

     

    Build Matrix

    그래프에서는 Laplacian Matrix, Adjacency Matrix, Diagonal Matrix 그리고 Feature Matrix를 사용한다. 논문에서는 각 행렬을 아래와 같이 표현하고 있다.

    \[ \mathcal{L} = D^{-\frac{1}{2}} A D^{-\frac{1}{2}}\quad \text{and} \quad A = \begin{bmatrix} 0 & R \\ R^{\top} & 0 \end{bmatrix} \]

    $\mathcal{L}$은 Laplacian Matrix를 나타내고, $A$는 Adjacency Matrix, $D$는 Diagonal Matrix를 의미한다. Feature Matrix는 모델의 입력으로 사용되며 0번째 $E$가 바로 Feature Matrix $X$가 된다. 수식에서 표현되는 $R \in \mathbb{R}^{M \times N}$은 user-item interaction matrix이다. $D^{-\frac{1}{2}}$를 Adjacency Matrix 양쪽에 곱한 이유는 정규화(normalization)를 해주기 때문이다. 

        def LaplacianMatrix(self, ratings):
            iids = ratings['business_id'] + self.num_users 
            matrix = sp.coo_matrix((ratings['stars'], (ratings['user_id'], ratings['business_id'])))
            
            upper_matrix = sp.coo_matrix((ratings['stars'], (ratings['user_id'], iids)))
            lower_matrix = matrix.transpose()
            lower_matrix.resize((self.num_items, self.num_users + self.num_items))
    
            A = sp.vstack([upper_matrix, lower_matrix])
            row_sum = (A > 0).sum(axis=1)
            row_sum = np.array(row_sum).flatten()
            D = np.power(row_sum, -0.5)
            D = sp.diags(D)
            L = D * A * D
            L = sp.coo_matrix(L)
            row = L.row 
            col = L.col
            idx = np.stack([row, col])
            idx = torch.LongTensor(idx)
            data = torch.FloatTensor(L.data)
            SparseL = torch.sparse.FloatTensor(idx, data)
            return SparseL

    코드를 살펴보면 먼저, Rating Matrix $R$를 입력으로 받고, Sparse Matrix로 변환한다. 이때 user_id는 사용자 ID를 의미하고, business_id는 제품 ID를 의미한다. Upper Matrix는 $[ 0 \quad R]$로 표현된 행렬의 윗 부분을 만들기 위함이고, Lower Matrix는 그 아래의 행렬 부분을 만들기 위함이다. 그 후 vstack 함수를 사용해 합친 후 Diagonal Matrix와 Adjacency Matrix를 곱해 Laplacian Matrix를 생성한다. 여기서는 PyTorch를 사용하고 있기 때문에 마지막에 torch.spase를 사용해 numpy 를 pytorch 로 변환한다. 

     

    Model Prediction

    NGCF 논문에서는 각 Layer 별로 출력된 Representation을 연결하고, weighted average, max pooling, LSTM 등과 같은 Layer를 추가로 연결해 최종 값을 도출한다. 

    \[ e^*_u = e^{(0)}_u || \cdots || e^{(L)}_u, \quad e^*_i = e^{(0)}_i || \cdots || e^{(L)}_i \]

    \[ \hat{y}_{\text{NGCF}} (u, i) = e^{*\top}_u e^*_i \]

    해당 코드에서는 MLP Layer를 사용하여 간단하게 구현해보았다.

        def forward(self, uids, iids):
            iids = self.num_users + iids 
    
            features = self.FeatureMatrix()
            final_emb = features.clone()
    
            for gnn in self.GNNLayers:
                features = gnn(self.L, self.I, features)
                features = self.leakyrelu(features)
                final_emb = torch.concat([final_emb, features],dim=-1)
    
            user_emb = final_emb[uids]
            item_emb = final_emb[iids]
    
            inputs = torch.concat([user_emb, item_emb], dim=-1)
            outs = self.fc_layer(inputs)
            return outs.flatten()

     

    Build DataLoader

    DataLoader를 만드는 것은 필수는 아니지만, Pytorch를 사용할 때에는 만들고 사용하는 것이 편리하다. 생각보다 간단하게 만들 수 있으니 되도록이면 사용하자.

    from torch.utils.data import Dataset, DataLoader
    
    class GraphDataset(Dataset):
        def __init__(self, dataframe):
            super(Dataset, self).__init__()
            
            self.uid = list(dataframe['user_id'])
            self.iid = list(dataframe['business_id'])
            self.ratings = list(dataframe['stars'])
        
        def __len__(self):
            return len(self.uid)
        
        def __getitem__(self, idx):
            uid = self.uid[idx]
            iid = self.iid[idx]
            rating = self.ratings[idx]
            
            return (uid, iid, rating)
            
    def get_loader(args, dataset, num_workers):
        d_set = GraphDataset(dataset)
        return DataLoader(d_set, batch_size=args.batch_size, num_workers=num_workers)

     

     

    Model Training

    def graph_evaluate(args, model, test_loader, criterion):
        output = []
        test_loss = 0
    
        model.eval()
        with torch.no_grad():
            for batch in tqdm(test_loader, desc='evaluating...'):
                batch = tuple(b.to(args.device) for b in batch)
                inputs = {'uids':   batch[0], 
                          'iids':   batch[1]}
                gold_y = batch[2].float()
                
                pred_y = model(**inputs)
                output.append(pred_y)
                
                loss = criterion(pred_y, gold_y)
                loss = torch.sqrt(loss)
                test_loss += loss.item()
        test_loss /= len(test_loader)
        return test_loss, output
    
    def graph_train(args, model, train_loader, valid_loader, optimizer, criterion):
        best_loss = float('inf')
        train_losses, valid_losses = [], []
        for epoch in range(1, args.num_epochs + 1):
            train_loss = 0.0
    
            model.train()
            for batch in tqdm(train_loader, desc='training...'):
                batch = tuple(b.to(args.device) for b in batch)
                inputs = {'uids':   batch[0], 
                          'iids':   batch[1]}
                
                gold_y = batch[2].float()
                
    
                pred_y = model(**inputs)
                
                loss = criterion(pred_y, gold_y)
                loss = torch.sqrt(loss)
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()
                train_loss += loss.item()
            train_loss /= len(train_loader)
            train_losses.append(train_loss)
    
            valid_loss , outputs = graph_evaluate(args, model, valid_loader, criterion)
            valid_losses.append(valid_loss)
            
    
            print(f'Epoch: [{epoch}/{args.num_epochs}]')
            print(f'Train Loss: {train_loss:.4f}\tValid Loss: {valid_loss:.4f}')
    
            if best_loss > valid_loss:
                best_loss = valid_loss
                if not os.path.exists(args.SAVE_PATH):
                    os.makedirs(args.SAVE_PATH)
                torch.save(model.state_dict(), os.path.join(args.SAVE_PATH, f'{model._get_name()}_parameters.pt'))
    
        return {
            'train_loss': train_losses, 
            'valid_loss': valid_losses
        }, outputs

    이로써 학습에 필요한 코드는 전부 작성되었다. 그렇다면 이제 실제 학습을 해보자.

     

    models = NGCF(args, d_train).to(args.device)
    
    optimizer = optim.Adam(models.parameters(), lr = 1e-3)
    criterion = nn.L1Loss()
    
    results = graph_train(args, models, train_loader, valid_loader, optimizer, criterion)

    최적화함수를 호출하고, 손실함수를 정의한다. 그런다음 실제 학습을 수행하면 된다. 본 코드에는 BPRLoss를 사용하지 않았다. 사용한 데이터는 Yelp 데이터를 사용하였으며, 본 논문에서 사용하는 Implicit 관점이 아니라 Explicit 관점으로 코드를 작성했다. 다시 말해, 평점 3.5 점 이상인 경우 1로 표기하고, 3.5 미만인 경우 0으로 표기하였다. BPRLoss는 실제로 1인 값과 0인 값을 가지고 학습하는 것을 의미한다. 

    \[ L_{BPR} = - \underset{u=1}{\overset{M}{\sum}} \underset{i \in \mathcal{N}_u}{\sum} \underset{j \notin \mathcal{N}_u}{\sum} \text{ln} \sigma (\hat{y}_{ui} - \hat{y}_{uj}) + \lambda || E^{(0)}||^2 \]

     

     

    실제 코드를 살펴보면 잘 학습되고 있는 것을 볼 수 있다. 전체 코드를 확인하고 싶으면 여기를 참고하면 된다.