初心者がGNNでレコメンドを実装してみた。

# Summary
GNN で movielens データに対してレコメンドを行います。
GNN の解説などは控えめに行い、実装に注力した記事になります。
ネット上にチュートリアルが存在するのですが、[こちら](https://colab.research.google.com/drive/1xpzn1Nvai1ygd_P5Yambc_oe4VBPK_ZT?usp=sharing)は未完成となっており動かすことができません。そこでそちらを完成させることにしました。

## GNN とは
GNN の解説は[この記事](https://cpp-learning.com/pytorch-geometric/)がとても分かりやすいです。

ここでは簡単に解説を行います。
GNN では通常のデータをグラフ構造として扱い、学習を行います。
グラフ構造というのは下記のような構造です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882383/ca6c0f34-fd66-ef2d-9620-00bbaefb9594.png)


ここでいう人や映画がノード、ノードをつなぐ線をエッジと呼びます。
今回のレコメンドではノードがつながっている学習データをもとに、正解の不明なテストデータのノードが繋がっているかどうかを予測します。
今回はノードが人と映画、二種類あり、このようなデータを Hetero と呼びます。

## movielens データとは
今回使用する moviekens データを見ていきましょう。
データは[こちら](https://drive.google.com/drive/u/0/folders/1tYshkbPV5u-cHJUchSQyNOBGemBSUSD7)になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882383/086a1d00-1a5c-cd79-3efa-4808ebdce6b6.png)


シンプルなデータセットで商品とユーザのみが入っていることがわかります。

## 問題設定
視聴予測を行います。
rating というカラムがありますが、実数値は使わず視聴有として扱います。
そして実際の問題を解く際にはユーザに映画をレコメンド、視聴履歴があればリコメンド成功、なければ失敗として扱います。

## 実装
それでは実際の実装に入っていきましょう。
私はローカルで実験を行っていますが、google colab でも動かせると思います。

### 前処理
まず、映画の属性を紐づけます。
映画の属性は文字列で入っているので分割してワンホットのカテゴリカルカラムとして扱います。
次に userId や movieId をカテゴリカルカラムにエンコーディングします。これは userId や movieId が文字列であったり、無駄に大きい配列であったりする際に処理を軽くするためです。

```prepare.py
import numpy as np
from torch_geometric.data import (
    HeteroData,
    InMemoryDataset,
    download_url,
    extract_zip,
)
import torch_geometric.transforms as T
import os
import pandas as pd
import torch
from torch.nn import Linear, Embedding
from torch_geometric.loader import LinkNeighborLoader
from torch_geometric.nn import SAGEConv, to_hetero
import random

class AverageMeter():
    """Computes and stores the average and current value"""
    def __init__(self):
        self.reset()

    def reset(self):
        self.val = 0
        self.avg = 0
        self.sum = 0
        self.count = 0

    def update(self, val, n=1):
        self.val = val
        self.sum += val * n
        self.count += n
        self.avg = self.sum / self.count

def seed_torch(seed=42):
    random.seed(seed)
    os.environ['PYTHONHASHSEED'] = str(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.backends.cudnn.deterministic = True
seed_torch()

movies = pd.read_csv('./data/movies.csv')[['movieId', 'genres']]
ratings = pd.read_csv('./data/ratings.csv')

# categorical カラムに
movie_feat_df = movies.set_index('movieId')['genres'].str.get_dummies('|')
movie_feat = torch.from_numpy(movie_feat_df.values).to(torch.float)

# ID の生成
ratings_user_id = pd.merge(ratings['userId'], unique_user_id_df, on='userId', how='left')
ratings_user_id = torch.from_numpy(ratings_user_id['mappedId'].values)

ratings_movie_id = pd.merge(ratings['movieId'], unique_movie_id_df, on='movieId', how='left')
ratings_movie_id = torch.from_numpy(ratings_movie_id['mappedId'].values)

# edge を設定
edge_index_user_to_movie = torch.stack([ratings_user_id, ratings_movie_id], dim=0)
```



### グラフデータの生成
現状あるデータはすべてテーブルデータになっています。
ですので、こちらをすべてグラフデータに置き換えます。
movie node には属性としてジャンルを与えます。
また、エッジはリバースすることで無向グラフとして扱っています。
無向グラフというのは向きがないグラフのことで、向きがあるグラフは有向グラフといいます。ユーザと商品はお互いにつながっているという考えのもと無向グラフにしました。
有向グラフにするケースとしては、例えば論文の引用は一方的に行われるものなので有向グラフにした方がいいかもしれません。

```graph.py
data = HeteroData()

# ノードの割当
data['user'].node_id = torch.arange(len(unique_user_ids))
data['movie'].node_id = torch.arange(len(unique_movie_ids))

# 属性の割当
data["movie"].x = movie_feat
data["user", "rates", "movie"].edge_index = edge_index_user_to_movie

# 無向グラフにする。
data = T.ToUndirected()(data)
```

### 学習データと検証データへの分割
学習データと検証データに分割します。
ここが厄介なところです。
学習データには視聴した履歴があります。このままのデータのみを学習すると、正例しか学習しないことになり負例を学習することができません。そのため、ここでは新しく負例を作成することにします。これを negative sampling といいます。
negative sampling には大まかに二種類あり、 static なものと dynamic なものです。
static なものは学習前に生成して学習。dynamic なものは学習しながら生成します。
簡単に言うと学習ごとに負例が異なるものに切り替わるかどうかという違いです。一般的にはいろいろなデータを学習できる dynamic negative sampling が優れているようですが、ここでは static negative sampling を行います。

```train_val.py
# 学習に使用される
# edge_index: node 同士の繋がり
# 評価に使用される。
# edge_label: つながっているかどうか。
# edge_label_index: node 同士の繋がり

transform = T.RandomLinkSplit(
    num_val=0.1, 
    num_test=0.1, 
    edge_types=("user", "rates", "movie"),
    rev_edge_types=("movie", "rev_rates", "user"),
    # メッセージパッシングと監視のためにトレーニングエッジが共有されなくなります．
    # その代わり， disjoint_train_ratio のエッジは，学習中のスーパービジョンでグランドトゥルースラベルとして使用されます．
    disjoint_train_ratio=0.3,# 監視用train edge の割合
    add_negative_train_samples=True,
    neg_sampling_ratio = 2
)

train_data, val_data, test_data = transform(data)
```

### model 構築
こちらも厄介なところです。
pytorch を扱ったことがないと少しハードルが高いかもしれません。
モデルは大きく分けて二つのモジュールから構成されます。
一つは 特徴量生成器、二つ目は分類器です。
GNN というモジュールが特徴量生成器でグラフ特徴量の生成を行っています。
Classifier というモジュールが分類器で特徴量を用いてユーザが映画を視聴するかどうかを予測します。

```model.py
class GNN(torch.nn.Module):
    def __init__(self, hidden_sizes):
        super().__init__()

        self.conv1 = SAGEConv(hidden_sizes, hidden_sizes)
        self.conv2 = SAGEConv(hidden_sizes, hidden_sizes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        x = self.conv2(x, edge_index)
        return x

class Classifier(torch.nn.Module):
    def forward(self, x_user, x_movie, edge_label_index):
        edge_feat_user = x_user[edge_label_index[0]]
        edge_feat_movie = x_movie[edge_label_index[1]]

        return (edge_feat_user * edge_feat_movie).sum(dim=-1)

class Model(torch.nn.Module):
    def __init__(self, hidden_sizes):
        super().__init__()

        self.movie_lin = Linear(20, hidden_sizes)
        self.user_emb = Embedding(data['user'].num_nodes, hidden_sizes)
        self.movie_emb = Embedding(data['movie'].num_nodes, hidden_sizes)

        self.gnn = GNN(hidden_sizes)
        self.gnn = to_hetero(self.gnn, metadata=data.metadata())
        
        self.classifier = Classifier()

    def forward(self, data: HeteroData):
        x_dict = {
            'user': self.user_emb(data['user'].node_id),
            'movie': self.movie_lin(data['movie']['x'][data['movie']['node_id']]) + self.movie_emb(data['movie'].node_id),
        }
        x_dict = self.gnn(x_dict, data.edge_index_dict)

        pred = self.classifier(
            x_dict['user'],
            x_dict['movie'],
            data['user', 'rates', 'movie'].edge_label_index,
        )
        
        return pred

model = Model(hidden_sizes=64)
print(model)

# output
#Model(
#  (movie_lin): Linear(in_features=20, out_features=64, bias=True)
#  (user_emb): Embedding(610, 64)
#  (movie_emb): Embedding(9724, 64)
#  (gnn): GraphModule(
#    (conv1): ModuleDict(
#      (user__rates__movie): SAGEConv(64, 64, aggr=mean)
#      (movie__rev_rates__user): SAGEConv(64, 64, aggr=mean)
#    )
#    (conv2): ModuleDict(
#      (user__rates__movie): SAGEConv(64, 64, aggr=mean)
#      (movie__rev_rates__user): SAGEConv(64, 64, aggr=mean)
#    )
#  )
#  (classifier): Classifier()
#)
```

GNN というところで何を行っているのかを少し見ていきましょう。
下記のようなグラフがあるとします。ここで一層の畳み込みを行うとユーザと映画の間で情報交換が行われます。ユーザには映画の情報が押し付けられるようなイメージです。この情報が押し付けられることをメッセージ伝播といいます。一層だと一回のメッセージ伝播、二層だと二回のメッセージ伝播になります。二回になるとユーザには商品と、その商品につながっていたユーザの情報が与えられます。このようにすると「近いノードの情報を得る」ということがGNNの本質であることがわかります。
例えば、アマゾンのショップを見てみると今人気の商品！！というのがありますよね？単純に全員に人気なんだからみんな買うでしょ？というレコメンドです。GNN では大人気の商品があるとそれはいろんなユーザにつながっているので、何回か畳み込むと自然にその商品の情報を得ることになり、自動でレコメンドの上位に挙がってきそうです。こう考えると「今まで人間が行っていた特徴量生成が自動的にやってくれる」。これがGNNの強みになりそうですね。逆に言えば、とても頭のいい人が特徴量生成を行い、モデルを作れるなら GNN は不要なのかもしれません。実際に competition だと、GNN が上位に来るケースはあまり見たことがありません。これは頭の強い人が作った特徴量 > GNN が作った特徴量 となっているからなのかもしれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882383/0f6e74b0-347f-2479-b629-75bc9b8ac969.png)


### 学習と評価
実際に学習を回していきましょう。
こちらも pytorch の基礎的な内容になっています。
簡単に説明をすると、機械学習ではデータの学習と評価を何回も繰り返します。
一回の学習、評価をエポックという単位で呼びます。

```train.py
def train_fn(data, model, criterion, optimizer, epoch, device):
    losses = AverageMeter()
    model.train()

    optimizer.zero_grad()
    data = data.to(device)
    labels  = data['user', 'rates', 'movie'].edge_label 
    
    output = model(data)
    
    loss = criterion(output, labels)
    losses.update(loss.item(), len(labels))

    loss.backward()
    optimizer.step()

    return losses.avg

def valid_fn(data, model, criterion, device):
    losses = AverageMeter()
    model.eval()

    data = data.to(device)
    labels  = data['user', 'rates', 'movie'].edge_label

    with torch.no_grad():  
        output = model(data)
    loss = criterion(output, labels)
    losses.update(loss.item(), len(labels))

    predictions = (torch.sigmoid(output).cpu().detach().numpy())
    return losses.avg, predictions

import tqdm
import torch.nn.functional as F

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=2e-3)
criterion = torch.nn.BCEWithLogitsLoss()

best_loss = np.inf
for epoch in range(100):
    train_loss = train_fn(train_data, model, criterion, optimizer, epoch, device)
    val_loss, preds = valid_fn(val_data, model, criterion, device)
    print(
        f'Epoch: {epoch} '
        f'Train loss: {train_loss} '
        f'Valid loss: {val_loss} '
        )
    if val_loss < best_loss:
        best_loss = val_loss
print(best_loss)

# output
# 0.33696436882019043
```

## reference
記事を書くにあたり下記の記事を参考にさせていただきました。
感謝いたします。
https://cpp-learning.com/pytorch-geometric/
https://pytorch-geometric.readthedocs.io/en/latest/notes/colabs.html
fatalerrors.org/a/message-passing-graph-neural-network.html

## 次回
1. 今回作った GNN をミニバッチで学習させます。
1. dynamic negative sampling と static negative sampling の比較を行います。

## 補足
今回学習した GNN は重みをゼロから学習しており、どうしても大きい学習率から学習を開始する必要がありそうです。その際、徐々に学習率を下げたりできるとよりいいモデルができそうです。
そんなときには scheduler を使いましょう。
https://qiita.com/kma-jp/items/81db6d5c549e50707e30