Heterogeneous Graphでグラフニューラルネットワークの学習をやってみた

この記事は、NTTコミュニケーションズ Advent Calendar 2022 20日目の記事です。

こんにちは。コミュニケーション&アプリケーションサービス部の石井です。
普段の業務では文章要約技術を用いたAPIサービス1の開発・運用に取り組んでおります。

この記事ではグラフニューラルネットワーク(GNN)、特に Heterogeneous Graph(異種グラフ) を扱ったGNNについて紹介していこうと思います。

本記事で扱う内容

この記事で取り扱う内容は以下です。

  • グラフニューラルネットワーク(GNN)とは
  • Heterogeneous Graph(異種グラフ)
  • 機械学習におけるグラフベースの問題設定
  • Pytorch-geometricによるモデル構築

GNNの概要と Heterogeneous Graph について簡単に説明をした後に、実際にモデルを作成していく流れで展開していきます。 本記事ではアルゴリズムの詳細な解説などは省略しますので、より深く興味がある方はリンクをつけておきますので論文や解説記事を参照してみてください。

グラフニューラルネットワークとは

グラフニューラルネットワーク(GNN)とはグラフで表現されたデータを深層学習で扱うためのニューラルネットワーク手法の総称です。グラフデータから表現抽出をして目的のタスクを解くというEnd2Endアプローチによる機械学習アルゴリズムとなります。メジャーな手法としては GCN2 や GraphSAGE3 などがあります。

GNN の仕組みについて GCN の手法を元に簡単に記載すると、グラフの頂点(ノード)の特徴量に対して隣接するノードの特徴量に重みを掛けたものを加えていく演算をすることで、対象ノードにグラフ構造の情報を加味した表現を獲得させるといった動きをします。より詳しい説明については distill4 というサイトに「Understanding Convolutions on Graphs」というGNNの解説記事があるのでそちらを参考にしてみてください。

Heterogeneous Graph

Heterogeneous Graphを理解する前提としてまずはグラフの定義から話していきます。
グラフ理論において、グラフとは頂点を示すノードとその間の関係であるエッジから表現されるデータ構造になります。つまり、ノード間をエッジで繋ぐことによってノードによるネットワーク構造を表現したものが、一般的によく目にするグラフと呼ばれるものになります。これを数式的に定義すると以下のようになります。

 G=(V,E)
 V: ノードの集合、 E: エッジの集合

そして、このグラフにおいて1種類のノードと、同じ意味合いのエッジによって関係を示したものを Homogeneous Graph と呼びます。一方で、複数の多様なノードとエッジを含んでいる関係のグラフを Heterogeneous Graph と言います。例えば、ソーシャルネットワークのような人と人の間を交友関係でリンクしたグラフは Homogeneous Graph であり、店舗利用関係のような人と店舗の間を購買実績でリンクしたグラフは Heterogeneous Graph となります。想起しやすいように図で示すと以下のようになります。

ちなみに GNN ベースのアルゴリズムの多くは入力が単一のノードとエッジを持つ、Homogeneous Graph を対象とした手法となっています(最近では HAT5 のような Heterogeneous Graph を対象としたアルゴリズムも増えてきています)。その上で、なぜ Heterogeneous Graph を扱う必要があるのかという問いについてですが、現実世界において観測対象をグラフ表現で構造化しようとした場合に、複数のノードまたはエッジによる関係を定義する頻度が高いからです。ある人と人の関係を構造化する場合よりも、ある人と物やサービスの関係を構造化する場合の方がパターンが多いのは容易に想像できます。

と、ここまで Heterogeneous Graph の話をしてきましたが、そもそも GNN 自体が深層学習分野の中でも近年注目されている技術であり、データ分析競技協会である KDD CUP 2021 では「OGB-LS」というカテゴリで3つのグラフTaskが取り扱われるなどその注目度の高さが伺えます。また、今年開催された国際会議である KDD 2022 のResearch Trackの中では全254の論文の中で約80以上もの論文がグラフに関連した内容を取り扱うなどトレンド領域の1つとなっていることが分かりますね。

グラフにおける問題設定

次にグラフにおける問題設定について少し触れます。
通常の機械学習の問題設定では分類や回帰といったタスクを一般的に考えますが、グラフを扱う場合にはグラフに適用した問題設定を検討する必要があります。この問題設定には大きく3つの種類があります。

1つはノードを対象としたタスク(Node Centric)で、グラフ中におけるノード単位の分類や回帰といったタスクを扱います。2つめはグラフを対象としたタスク(Graph Centric)で、グラフ単位として分類や回帰といったタスクを扱います。グラフ単位のタスクは活用イメージがしづらいかと思いますが、化合物の分類などグラフが複数存在するパターンを想像してもらえると分かりやすいかと思います。最後はエッジを対象としたタスク(Edge Centric)で、各々ノード間のエッジに対して予測をして、「エッジが形成されるのか」や「エッジのクラスは何か」といったタスクを扱います。このようにグラフを扱う場合には自身の目的に応じたタスク設計が必要になるため、問題設定をしっかり検討した上で必要なデータ収集や実装を行っていきます。

また、もう少しタスクの補足をしておくと、上記の問題設定に加えて「trunsductive」と「inductive」と言う学習と推論時の状況について考慮しておくことも重要になります。

「trunsductive」とは学習と推論で同じグラフを扱う場合のことを指し、「inductive」は学習データにない新しいグラフを扱う場合のことを示します( semi-inductive6 といった考え方も存在します)。なぜこのような問題設定の違いを意識する必要があるかというと、これは GNN のモデル構築にて選択するアルゴリズムが異なってくるためになります7。そのため、「trunsductive」と「inductive」どちらかによって選択可能なアルゴリズムに制約が出てくることに注意してください。 ただ、一般的には新しい未知のノードやエッジに対して予測を行いたいといった場合の活用シーンの方が多いと考えられるため、「inductive」な問題設定をベースとして考えておけばまずは良いかと思います。

実際に試してみた

ここからは実際に Heterogeneous Graph を扱った GNN のモデルを構築してみようと思います。

今回は Kaggle で公開されている「Recipes and Reviews」のオープンデータを利用します。このデータは Food.com8 と言う海外のレシピ共有サイトより料理レシピとそのレシピに対してのユーザレビューの情報をデータ収集したものになります。料理レシピのデータにはレシピにおけるメタ情報と定量的な栄養素といった特徴量を含んでおり、ユーザレビューのデータはあるユーザが該当する料理レシピを5段階で評価した内容が含まれています。データサイズについても500,000以上のレシピ数と1,400,000のレビュー数があるため比較的ボリュームの大きいデータとなっています。

利用するフレームワークですが、Pytroch-geometric を用いて実装を行っていきます9。Pytorch-geometricでは、バージョン2.0.0より Heterogeneous Graph をサポートしています。そのため、Heterogeneous Graph を対象とするモデルを作成する場合にはバージョンに注意してご利用ください。

では、問題設定としては以下を考えてみようと思います。

ユーザとレシピの関係による二部グラブ構造の Heterogeneous Graph を定義して、ユーザがレシピに興味・関心を示すかを予測するタスク(リンク予測)を解こうと思います。データ内容はユーザによるレシピの評価を意味するため、評価したという事実を興味・関心があるという区分として扱うのは厳密には正しくはないとは思いますが、今回は便宜上このような問題設定とします。

データ準備

グラフデータ整形

データの読み込みからテーブルデータをグラフデータに変換するための処理を記述します。元となるデータは data/ のフォルダに配置しているためご自身の環境に合わせて適切に設定してください。
各ノードについてのID割り当てと各ノードIDによる COO形式10 での集合リストによってエッジを表現してグラフデータを定義します。

import os
import numpy as np
import pandas as pd
from tqdm import tqdm

from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib.pyplot as plt

import torch
import torch.nn.functional as F
from torch import Tensor
from torch.nn import Module

import torch_geometric
import torch_geometric.transforms as T
from torch_geometric.nn import SAGEConv, to_hetero
from torch_geometric.data import HeteroData
from torch_geometric.loader import LinkNeighborLoader

# データの読み込み(pandas)
df_recipes = pd.read_csv('../data/food/recipes.csv')
df_reviews = pd.read_csv('../data/food/reviews.csv')

# データ準備
df_reviews = df_reviews[df_reviews.RecipeId.isin(df_recipes["RecipeId"].unique())]  # 不要データ除外
df_recipes['RecipeServings'] = df_recipes['RecipeServings'].fillna(df_recipes['RecipeServings'].median())   # 欠損値補完

# ユーザノードとレシピノードのIDマップ作成
unique_user_id = df_reviews["AuthorId"].unique()
unique_user_id = pd.DataFrame(
    data={
        "user_id": unique_user_id,
        "mappedID": pd.RangeIndex(len(unique_user_id)),
    }
)
unique_recipe_id = df_reviews["RecipeId"].unique()
unique_recipe_id = pd.DataFrame(
    data={
        "recipe_id": unique_recipe_id,
        "mappedID": pd.RangeIndex(len(unique_recipe_id)),
    }
)
review_user_id = pd.merge(
    df_reviews["AuthorId"],
    unique_user_id,
    left_on="AuthorId",
    right_on="user_id",
    how="left",
)
review_recipe_id = pd.merge(
    df_reviews["RecipeId"],
    unique_recipe_id,
    left_on="RecipeId",
    right_on="recipe_id",
    how="left",
)

# ユーザIDとレシピIDのエッジ情報をTensorへ変換
tensor_review_user_id = torch.from_numpy(review_user_id["mappedID"].values)
tensor_review_recipe_id = torch.from_numpy(review_recipe_id["mappedID"].values)
tensor_edge_index_user_to_recipe = torch.stack(
    [tensor_review_user_id, tensor_review_recipe_id],
    dim=0,
)

前処理

レシピノードが持つ特徴量の前処理として標準化を行います。
今回利用する特徴量は既存のデータセット中に含まれる該当レシピの糖分や油分といった料理における構成要素のみをパラメータとして利用します。本来であればこのフェーズで特徴量エンジニアリングなどを行いますが今回は本題から外れてしまうのでスキップします。

# レシピノードの特徴量定義
recipe_feature_cols = [
    "Calories", "FatContent", "SaturatedFatContent",
    "CholesterolContent", "SodiumContent", "CarbohydrateContent",
    "FiberContent", "SugarContent", "ProteinContent",
    "RecipeServings",
]
df_recipes_feature = pd.merge(df_recipes, unique_recipe_id, left_on='RecipeId', right_on='recipe_id', how='left')
df_recipes_feature = df_recipes_feature.sort_values('mappedID').set_index('mappedID')
df_recipes_feature = df_recipes_feature[df_recipes_feature.index.notnull()]
df_recipes_feature = df_recipes_feature[recipe_feature_cols]

# 標準化
scaler = StandardScaler()
scaler.fit(df_recipes_feature)
scaler.transform(df_recipes_feature)
df_recipes_feature = pd.DataFrame(scaler.transform(df_recipes_feature), columns=df_recipes_feature.columns)

# レシピノードの特徴量をTensorへ変換
tensor_recipes_feature = torch.from_numpy(df_recipes_feature.values).to(torch.float)

データローダー

ここまで定義してきたデータを用いて Pytorch で扱えるデータセットとしてデータローダーを作成します。
データは RandomLinkSplit を用いてエッジに対してのデータ分割を行い、その後に LinkNeighborLoader でエッジベースのミニバッチを作成するデータローダーを定義します。この LinkNeighborLoader では全てのエッジの中からランダムサンプリングを適用し、そのエッジの隣接ノードから更にサンプリングを行うことで、全てのノードを使ったサブグラフによるミニバッチデータ作成を実施しています。

# HeteroDataオブジェクトの作成
data = HeteroData()
data['user'].node_id = torch.arange(len(unique_user_id))
data['recipe'].node_id = torch.arange(len(unique_recipe_id))
data['recipe'].x = tensor_recipes_feature
data['user', 'review', 'recipe'].edge_index = tensor_edge_index_user_to_recipe
data = T.ToUndirected()(data)

# 学習・評価用のデータ分割
transform = T.RandomLinkSplit(
    num_val=0.1,
    num_test=0.1, 
    disjoint_train_ratio=0.3,
    neg_sampling_ratio=2,
    add_negative_train_samples=False,
    edge_types=("user", "review", "recipe"),
    rev_edge_types=("recipe", "rev_review", "user"), 
)
train_data, val_data, test_data=transform(data)

# 学習用データローダー定義
edge_label_index = train_data["user", "review", "recipe"].edge_label_index
edge_label = train_data["user", "review", "recipe"].edge_label
train_loader = LinkNeighborLoader(
    data=train_data,
    num_neighbors=[20, 10],
    neg_sampling_ratio=2,
    edge_label_index=(("user", "review", "recipe"), edge_label_index),
    edge_label=edge_label,
    batch_size=256,
    shuffle=True,
)

# 検証用データローダー定義
edge_label_index = val_data["user", "review", "recipe"].edge_label_index
edge_label = val_data["user", "review", "recipe"].edge_label
val_loader = LinkNeighborLoader(
    data=val_data,
    num_neighbors=[20, 10],
    edge_label_index=(("user", "review", "recipe"), edge_label_index),
    edge_label=edge_label,
    batch_size=3 * 256,
    shuffle=False,
)

モデル学習

モデル定義

モデル全体像の簡単なアーキテクチャを説明すると、初めにユーザノードとレシピノードを分散表現に変換した後で、2層の GNN レイヤーにて分散表現から重要特徴量を抽出していき、最後に異なるノード間のエッジ存在確率を出力するようなモデルとなっています。また、GNN レイヤーにおけるアルゴリズムには GraphSAGE を用いており、これにより inductive な問題設定に対応したモデルとなるように配慮しています。

class GNN(Module):
    def __init__(self, hidden_channels: int):
        super().__init__()
        self.conv1 = SAGEConv(hidden_channels, hidden_channels)
        self.conv2 = SAGEConv(hidden_channels, hidden_channels)

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


class Classifier(Module):
    def forward(
        self, x_user: Tensor, x_recipe: Tensor, edge_label_index: Tensor
    ) -> Tensor:
        edge_feat_user = x_user[edge_label_index[0]]
        edge_feat_recipe = x_recipe[edge_label_index[1]]

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


class Model(Module):
    def __init__(self, hidden_channels: int):
        super().__init__()
        self.recipe_lin = torch.nn.Linear(10, hidden_channels)
        self.user_emb = torch.nn.Embedding(data["user"].num_nodes, hidden_channels)
        self.recipe_emb = torch.nn.Embedding(data["recipe"].num_nodes, hidden_channels)
        self.gnn = GNN(hidden_channels)
        self.gnn = to_hetero(self.gnn, metadata=data.metadata())
        self.classifier = Classifier()

    def forward(self, data: HeteroData) -> Tensor:
        x_dict = {
            "user": self.user_emb(data["user"].node_id),
            "recipe": self.recipe_lin(data["recipe"].x) + self.recipe_emb(data["recipe"].node_id),
        }

        x_dict = self.gnn(x_dict, data.edge_index_dict)

        pred = self.classifier(
            x_dict["user"],
            x_dict["recipe"],
            data["user", "review", "recipe"].edge_label_index,
        )

        return pred

学習と評価

学習用と検証用のデータローダーを用いてモデル学習とそのモデルの評価を行なっていきます。 評価はリンク予測によるエッジがあるかないかの2値分類となるため ROC-AUC11 で精度を確認してみようと思います。

def train(model, loader, device, optimizer, epoch):
    model.train()
    for epoch in range(1, epoch):
        total_loss = total_samples = 0
        for batch_data in tqdm(loader):
            optimizer.zero_grad()
            batch_data = batch_data.to(device)
            pred = model(batch_data)
            loss = F.binary_cross_entropy_with_logits(
                pred, batch_data["user", "review", "recipe"].edge_label
            )
            loss.backward()
            optimizer.step()
            total_loss += float(loss) * pred.numel()
            total_samples += pred.numel()
        print(f"Epoch: {epoch:04d}, Loss: {total_loss / total_samples:.4f}")

def validation(model, loader, device, optimizer):
    y_preds = []
    y_trues = []
    model.eval()
    for batch_data in tqdm(loader):
        with torch.no_grad():
            batch_data = batch_data.to(device)
            pred = model(batch_data)
            y_preds.append(pred)
            y_trues.append(batch_data["user", "review", "recipe"].edge_label)

    y_pred = torch.cat(y_preds, dim=0).cpu().numpy()
    y_true = torch.cat(y_trues, dim=0).cpu().numpy()
    auc = roc_auc_score(y_true, y_pred)
    return auc, y_pred, y_true


# パラメータセット
model = Model(hidden_channels=64)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
model = model.to(device)

# 学習・評価
train(model, train_loader, device, optimizer, 6)
auc, y_pred, y_true = validation(model, val_loader, device, optimizer)

# 精度確認(ROC-AUC曲線)
fpr, tpr, thresholds = roc_curve(y_true, y_pred)
plt.plot(fpr, tpr, label=f"AUC: {auc:.3f}")
plt.xlabel('FPR: False positive rate')
plt.ylabel('TPR: True positive rate')
plt.legend(loc='lower right')
plt.grid()

作成したモデルを用いて、検証用データより ROC-AUC を確認しました。
結果としてROC-AUCが 0.974 という数値でしたので、比較的高精度の予測が行えるモデルとなりました。

また、定量的な精度指標に加えて、予測モデルを使ってあるユーザノード(特定ユーザ)に対してランダム抽出したレシピに興味を持つかといったリンク予測がどれくらい当てられているのかを可視化してみました。

図の見方は真ん中のオレンジ色の丸が対象のユーザノードを示しており、その周りにある緑色のノードが興味を示す正例レシピノードで、グレー色が興味を示さない負例レシピノードとなります。上段の左図が正解データによるユーザとレシピの関係で、右図が予測結果によるユーザとレシピの関係です。下段の図はこれらの正解データの図と予測結果の図より正解と予測が一致するノードを緑色、不一致のノードを赤色で表現した図です。これを見ると興味を示すべきレシピに対して、興味がないと判別している予測がいくつかありますが、概ね予測がうまくできていることが見て取れますね。

終わりに

今回は Heterogeneous Graph の紹介から GNN でのモデリング方法について紹介しました。グラフデータはちょっと癖があるため扱いづらい部分もありますが、Pytorch-geometric などのフレームワークを用いることである程度簡単に実装できるようになっています。GNN が扱えるようになると問題解決の幅も広がっていくかと思いますので、興味がある方は是非試してみてください。

アドベントカレンダーも終盤ですが最後まで楽しんでいってください!


  1. https://www.ntt.com/about-us/press-releases/news/article/2020/0423.html
  2. M.Schlichtkrull, T.N.Kipf, P.Bloem, R.V.D.Berg, I.Titov, and M.Welling, "Modeling relational data with graph convolutional networks", in European Semantic Web Conference, 2018.
  3. W.Hamilton, Z.Ying, and J.Leskovec, "Inductive representation learning on large graphs", in NeurIPS, 2017.
  4. https://distill.pub/
  5. Wang, Xiao, et al. "Heterogeneous graph attention network." The world wide web conference. 2019.
  6. Ali, Mehdi, et al. "Improving Inductive Link Prediction Using Hyper-relational Facts." International Semantic Web Conference. Springer, Cham, 2021.
  7. SONG, J. AND YU, K., 2021. "Framework for Indoor Elements Classification via Inductive Learning on Floor Plan Graphs." ISPRS International Journal of Geo-Information, Volume 10.
  8. https://www.food.com/
  9. Pytorch-geometric以外にも DGLPytorch-BigGraph などのライブラリがあります。
  10. 疎行列を表現する格納方式の1つで、列・行・データの3つの1次元配列により疎行列を表現するデータ形式です。
  11. ROC-AUC は二値分類のタスクに対する評価指標の1つ。範囲として 0.0 〜 1.0 の値をとり、1.0 に近づくほど予測精度が高いことを示す。
© NTT Communications Corporation All Rights Reserved.